Files
zwift-offline/zwift_offline.py

2727 lines
118 KiB
Python

#!/usr/bin/env python
import calendar
import datetime
import logging
import os
import signal
import platform
import random
import sys
import tempfile
import time
import math
import threading
import re
import smtplib, ssl
import requests
import json
from copy import copy
from functools import wraps
from io import BytesIO
from shutil import copyfile
from logging.handlers import RotatingFileHandler
from urllib.parse import unquote
import jwt
from flask import Flask, request, jsonify, redirect, render_template, url_for, flash, session, abort, 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.descriptor import FieldDescriptor
from google.protobuf.json_format import MessageToJson, MessageToDict, Parse
from protobuf_to_dict import protobuf_to_dict, TYPE_CALLABLE_MAP
from flask_sqlalchemy import sqlalchemy, SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
sys.path.append(os.path.join(sys.path[0], 'protobuf')) # otherwise import in .proto does not work
import protobuf.udp_node_msgs_pb2 as udp_node_msgs_pb2
import protobuf.tcp_node_msgs_pb2 as tcp_node_msgs_pb2
import protobuf.activity_pb2 as activity_pb2
import protobuf.goal_pb2 as goal_pb2
import protobuf.login_response_pb2 as login_response_pb2
import protobuf.per_session_info_pb2 as per_session_info_pb2
import protobuf.periodic_info_pb2 as periodic_info_pb2
import protobuf.profile_pb2 as profile_pb2
import protobuf.segment_result_pb2 as segment_result_pb2
import protobuf.world_pb2 as world_pb2
import protobuf.zfiles_pb2 as zfiles_pb2
import protobuf.hash_seeds_pb2 as hash_seeds_pb2
import protobuf.events_pb2 as events_pb2
import protobuf.variants_pb2 as variants_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 os.name == 'nt' and platform.release() == '10' and platform.version() >= '10.0.14393':
# Fix ANSI color in Windows 10 version 10.0.14393 (Windows Anniversary Update)
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
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
try:
# Ensure storage dir exists
if not os.path.isdir(STORAGE_DIR):
os.makedirs(STORAGE_DIR)
except IOError as e:
logger.error("failed to create storage dir (%s): %s", STORAGE_DIR, str(e))
sys.exit(1)
SSL_DIR = "%s/ssl" % SCRIPT_DIR
DATABASE_INIT_SQL = "%s/initialize_db.sql" % SCRIPT_DIR
DATABASE_PATH = "%s/zwift-offline.db" % STORAGE_DIR
DATABASE_CUR_VER = 2
PACE_PARTNERS_DIR = "%s/pace_partners" % SCRIPT_DIR
BOTS_DIR = "%s/bots" % SCRIPT_DIR
# 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
MULTIPLAYER = False
credentials_key = None
if os.path.exists("%s/multiplayer.txt" % STORAGE_DIR):
MULTIPLAYER = True
try:
if not os.path.isdir(LOGS_DIR):
os.makedirs(LOGS_DIR)
except IOError as e:
logger.error("failed to create logs dir (%s): %s", LOGS_DIR, str(e))
sys.exit(1)
from logging.handlers import RotatingFileHandler
logHandler = RotatingFileHandler('%s/zoffline.log' % LOGS_DIR, maxBytes=1000000, backupCount=10)
logger.addHandler(logHandler)
try:
from cryptography.fernet import Fernet
encrypt = True
except ImportError:
logger.warn("cryptography is not installed. Uploaded garmin_credentials.txt will not be encrypted.")
encrypt = False
if encrypt:
OLD_KEY_FILE = "%s/garmin-key.txt" % STORAGE_DIR
CREDENTIALS_KEY_FILE = "%s/credentials-key.txt" % STORAGE_DIR
if os.path.exists(OLD_KEY_FILE): # check if we need to migrate from the old filename to new
os.rename(OLD_KEY_FILE, CREDENTIALS_KEY_FILE)
if not os.path.exists(CREDENTIALS_KEY_FILE):
with open(CREDENTIALS_KEY_FILE, 'wb') as f:
f.write(Fernet.generate_key())
with open(CREDENTIALS_KEY_FILE, 'rb') as f:
credentials_key = f.read()
try:
with open('%s/strava-client.txt' % STORAGE_DIR, 'r') as f:
client_id = f.readline().rstrip('\r\n')
client_secret = f.readline().rstrip('\r\n')
except Exception as exc:
#logger.warn('strava-client: %s' % repr(exc))
client_id = '28117'
client_secret = '41b7b7b76d8cfc5dc12ad5f020adfea17da35468'
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'] = 1024 * 1024
db = SQLAlchemy(app)
online = {}
global_pace_partners = {}
global_bots = {}
global_ghosts = {}
ghosts_enabled = {}
player_update_queue = {}
zc_connect_queue = {}
player_partial_profiles = {}
save_ghost = None
restarting = False
restarting_in_minutes = 0
reload_pacer_bots = False
class User(UserMixin, db.Model):
player_id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
first_name = db.Column(db.String(100), nullable=False)
last_name = db.Column(db.String(100), nullable=False)
pass_hash = db.Column(db.String(100), 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.utcnow() + 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 Exception as exc:
logger.warn('jwt.decode: %s' % repr(exc))
return None
id = data.get('user')
if id:
return User.query.get(id)
return None
class AnonUser(User, AnonymousUserMixin, db.Model):
username = "zoffline"
first_name = "z"
last_name = "offline"
enable_ghosts = True
def is_authenticated(self):
return True
class PartialProfile:
first_name = ''
last_name = ''
country_code = 0
route = 0
class Online:
total = 0
richmond = 0
watopia = 0
london = 0
makuriislands = 0
newyork = 0
innsbruck = 0
yorkshire = 0
france = 0
paris = 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'
}
def jwt_encode(payload, key, **kwargs):
# For pyjwt >= 2.0.0 compatibility (Issue #108)
if jwt.__version__[0] == '1':
return jwt.encode(payload, key, **kwargs).decode('utf-8')
else:
return jwt.encode(payload, key, **kwargs)
def get_utc_date_time():
return datetime.datetime.utcnow()
def get_seconds_from_date_time(dt):
return (time.mktime(dt.timetuple()) * 1000.0 + dt.microsecond / 1000.0) / 1000
def get_utc_time():
dt = get_utc_date_time()
return get_seconds_from_date_time(dt)
def get_time():
dt = datetime.datetime.now()
return get_seconds_from_date_time(dt)
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
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 get_partial_profile(player_id):
if not player_id in player_partial_profiles:
#Read from disk
if player_id > 2000000 and player_id < 3000000:
profile_file = '%s/%s/profile.bin' % (PACE_PARTNERS_DIR, player_id)
elif player_id > 3000000 and player_id < 4000000:
profile_file = '%s/%s/profile.bin' % (BOTS_DIR, player_id)
else:
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
if os.path.isfile(profile_file):
try:
with open(profile_file, 'rb') as fd:
profile = profile_pb2.PlayerProfile()
profile.ParseFromString(fd.read())
partial_profile = PartialProfile()
partial_profile.first_name = profile.first_name
partial_profile.last_name = profile.last_name
partial_profile.country_code = profile.country_code
for f in profile.public_attributes:
#0x69520F20=1766985504 - crc32 of "PACE PARTNER - ROUTE"
#TODO: -1021012238: figure out
if f.id == 1766985504 or f.id == -1021012238: #-1021012238 == 3273955058
if f.number_value >= 0:
partial_profile.route = toSigned(f.number_value, 4)
else:
partial_profile.route = -toSigned(-f.number_value, 4)
break
player_partial_profiles[player_id] = partial_profile
except Exception as exc:
logger.warn('get_partial_profile: %s' % repr(exc))
return None
else: return None
return player_partial_profiles[player_id]
def get_course(state):
return (state.f19 & 0xff0000) >> 16
def is_nearby(player_state1, player_state2, range = 100000):
if player_state1 is None or player_state2 is None:
return False
try:
if player_state1.watchingRiderId == player_state2.id or player_state2.watchingRiderId == player_state1.id:
return True
course1 = get_course(player_state1)
course2 = get_course(player_state2)
if course1 == course2:
x1 = int(player_state1.x)
x2 = int(player_state2.x)
if x1 - range <= x2 and x1 + range >= x2:
z1 = int(player_state1.z)
z2 = int(player_state2.z)
if z1 - range <= z2 and z1 + range >= z2:
a1 = int(player_state1.y_altitude)
a2 = int(player_state2.y_altitude)
if a1 - range <= a2 and a1 + range >= a2:
return True
except Exception as exc:
logger.warn('is_nearby: %s' % repr(exc))
pass
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, 'sha256')
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")
@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 check_password_hash(user.pass_hash, password):
login_user(user, remember=True)
user.remember = remember
db.session.commit()
profile_dir = os.path.join(STORAGE_DIR, str(user.player_id))
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
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")
@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:
try:
with open('%s/gmail_credentials.txt' % STORAGE_DIR, 'r') as f:
sender_email = f.readline().rstrip('\r\n')
password = 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" % (server_ip, user.get_token())
message.attach(MIMEText(content, 'plain'))
server.sendmail(sender_email, username, message.as_string())
server.close()
flash("E-mail sent.")
except Exception as exc:
logger.warn('send e-mail: %s' % repr(exc))
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/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, 'sha256')
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, 'sha256')
current_user.pass_hash = hashed_pwd
db.session.commit()
flash("Password changed.")
return render_template("reset.html", username=current_user.username)
@app.route("/strava", methods=['GET'])
@login_required
def strava():
try:
from stravalib.client import Client
except ImportError as exc:
logger.warn('stravalib: %s' % repr(exc))
flash("stravalib is not installed. Skipping Strava authorization attempt.")
return redirect('/user/%s/' % current_user.username)
client = Client()
url = client.authorization_url(client_id=client_id,
redirect_uri='https://launcher.zwift.com/authorization',
scope='activity:write')
return redirect(url)
@app.route("/authorization", methods=["GET", "POST"])
@login_required
def authorization():
from stravalib.client import Client
try:
client = Client()
code = request.args.get('code')
token_response = client.exchange_code_for_token(client_id=client_id, client_secret=client_secret, code=code)
with open(os.path.join(STORAGE_DIR, str(current_user.player_id), 'strava_token.txt'), 'w') as f:
f.write(client_id + '\n');
f.write(client_secret + '\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. Go to \"Upload\" to remove authorization.")
except Exception as exc:
logger.warn('Strava: %s' % repr(exc))
flash("Strava canceled.")
flash("Please close this window and return to Zwift Launcher.")
return render_template("strava.html", username=current_user.username)
@app.route("/profile/<username>/", methods=["GET", "POST"])
@login_required
def profile(username):
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)
username = request.form['username']
password = request.form['password']
profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
session = requests.session()
try:
access_token, refresh_token = online_sync.login(session, username, password)
try:
profile = online_sync.query_player_profile(session, access_token)
with open('%s/profile.bin' % SCRIPT_DIR, 'wb') as f:
f.write(profile)
online_sync.logout(session, refresh_token)
os.rename('%s/profile.bin' % SCRIPT_DIR, '%s/profile.bin' % profile_dir)
flash("Zwift profile installed locally.")
except Exception as exc:
logger.warn('Zwift profile: %s' % repr(exc))
flash("Error downloading profile.")
if request.form.get("safe_zwift", None) != None:
try:
file_path = os.path.join(profile_dir, 'zwift_credentials.txt')
with open(file_path, 'w') as f:
f.write(username + '\n');
f.write(password + '\n');
if credentials_key is not None:
with open(file_path, 'rb') as fr:
zwift_credentials = fr.read()
cipher_suite = Fernet(credentials_key)
ciphered_text = cipher_suite.encrypt(zwift_credentials)
with open(file_path, 'wb') as fw:
fw.write(ciphered_text)
flash("Zwift credentials saved.")
except Exception as exc:
logger.warn('zwift_credentials: %s' % repr(exc))
flash("Error saving 'zwift_credentials.txt' file.")
except Exception as exc:
logger.warn('online_sync.login: %s' % repr(exc))
flash("Invalid username or password.")
return render_template("profile.html", username=current_user.username)
@app.route("/garmin/<username>/", methods=["GET", "POST"])
@login_required
def garmin(username):
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)
username = request.form['username']
password = request.form['password']
try:
file_path = os.path.join(STORAGE_DIR, str(current_user.player_id), 'garmin_credentials.txt')
with open(file_path, 'w') as f:
f.write(username + '\n');
f.write(password + '\n');
if credentials_key is not None:
with open(file_path, 'rb') as fr:
garmin_credentials = fr.read()
cipher_suite = Fernet(credentials_key)
ciphered_text = cipher_suite.encrypt(garmin_credentials)
with open(file_path, 'wb') as fw:
fw.write(ciphered_text)
flash("Garmin credentials saved.")
except Exception as exc:
logger.warn('garmin_credentials: %s' % repr(exc))
flash("Error saving 'garmin_credentials.txt' file.")
return render_template("garmin.html", username=current_user.username)
@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),
online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes, server_ip=os.path.exists(SERVER_IP_FILE))
def send_message_to_all_online(message, sender='Server'):
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(get_utc_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()
for recieving_player_id in online.keys():
if not recieving_player_id in player_update_queue:
player_update_queue[recieving_player_id] = list()
player_update_queue[recieving_player_id].append(player_update.SerializeToString())
def send_restarting_message():
global restarting
global restarting_in_minutes
while restarting:
send_message_to_all_online('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_to_all_online(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('/user/%s/' % 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_to_all_online(message)
discord.send_message(message)
return redirect('/user/%s/' % 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('/user/%s/' % current_user.username)
@app.route("/upload/<username>/", methods=["GET", "POST"])
@login_required
def upload(username):
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 ['profile.bin', 'strava_token.txt', 'garmin_credentials.txt', 'zwift_credentials.txt']:
file_path = os.path.join(profile_dir, uploaded_file.filename)
uploaded_file.save(file_path)
if uploaded_file.filename == 'garmin_credentials.txt' and credentials_key is not None:
with open(file_path, 'rb') as fr:
garmin_credentials = fr.read()
cipher_suite = Fernet(credentials_key)
ciphered_text = cipher_suite.encrypt(garmin_credentials)
with open(file_path, 'wb') as fw:
fw.write(ciphered_text)
if uploaded_file.filename == 'zwift_credentials.txt' and credentials_key is not None:
with open(file_path, 'rb') as fr:
garmin_credentials = fr.read()
cipher_suite = Fernet(credentials_key)
ciphered_text = cipher_suite.encrypt(garmin_credentials)
with open(file_path, 'wb') as fw:
fw.write(ciphered_text)
flash("File %s uploaded." % uploaded_file.filename)
else:
flash("Invalid file name.")
name = ''
profile = None
profile_file = os.path.join(profile_dir, 'profile.bin')
if os.path.isfile(profile_file):
stat = os.stat(profile_file)
profile = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
with open(profile_file, 'rb') as fd:
p = profile_pb2.PlayerProfile()
p.ParseFromString(fd.read())
name = "%s %s" % (p.first_name, p.last_name)
token = None
token_file = os.path.join(profile_dir, 'strava_token.txt')
if os.path.isfile(token_file):
stat = os.stat(token_file)
token = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
garmin = None
garmin_file = os.path.join(profile_dir, 'garmin_credentials.txt')
if os.path.isfile(garmin_file):
stat = os.stat(garmin_file)
garmin = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
zwift = None
zwift_file = os.path.join(profile_dir, 'zwift_credentials.txt')
if os.path.isfile(zwift_file):
stat = os.stat(zwift_file)
zwift = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
return render_template("upload.html", username=current_user.username, profile=profile, name=name, token=token, garmin=garmin, zwift=zwift)
@app.route("/download/profile.bin", methods=["GET"])
@login_required
def download_profile():
player_id = current_user.player_id
profile_dir = os.path.join(STORAGE_DIR, str(player_id))
profile_file = os.path.join(profile_dir, 'profile.bin')
if os.path.isfile(profile_file):
return send_file(profile_file, attachment_filename='profile.bin')
@app.route("/download/<int:player_id>/avatarLarge.jpg", methods=["GET"])
def download_avatarLarge(player_id):
profile_dir = os.path.join(STORAGE_DIR, str(player_id))
profile_file = os.path.join(profile_dir, 'avatarLarge.jpg')
if os.path.isfile(profile_file):
return send_file(profile_file, mimetype='image/jpeg', attachment_filename='avatarLarge.jpg')
else:
return '', 404
@app.route("/delete/<filename>", methods=["GET"])
@login_required
def delete(filename):
player_id = current_user.player_id
if filename not in ['profile.bin', 'strava_token.txt', 'garmin_credentials.txt', 'zwift_credentials.txt']:
return '', 403
profile_dir = os.path.join(STORAGE_DIR, str(player_id))
delete_file = os.path.join(profile_dir, filename)
if os.path.isfile(delete_file):
os.remove("%s" % delete_file)
return redirect(url_for('upload', username=current_user))
@app.route("/logout/<username>")
@login_required
def logout(username):
logout_user()
flash("Successfully logged out.")
return redirect(url_for('login'))
####
# Set up protobuf_to_dict call map
type_callable_map = copy(TYPE_CALLABLE_MAP)
# Override base64 encoding of byte fields
type_callable_map[FieldDescriptor.TYPE_BYTES] = str
# sqlite doesn't support uint64 so make them strings
type_callable_map[FieldDescriptor.TYPE_UINT64] = str
def insert_protobuf_into_db(table_name, msg):
msg_dict = protobuf_to_dict(msg, type_callable_map=type_callable_map)
columns = ', '.join(list(msg_dict.keys()))
placeholders = ':'+', :'.join(list(msg_dict.keys()))
query = 'INSERT INTO %s (%s) VALUES (%s)' % (table_name, columns, placeholders)
db.session.execute(query, msg_dict)
db.session.commit()
# XXX: can't be used to 'nullify' a column value
def update_protobuf_in_db(table_name, msg, id):
try:
# If protobuf has an id field and it's uint64, make it a string
id_field = msg.DESCRIPTOR.fields_by_name['id']
if id_field.type == id_field.TYPE_UINT64:
id = str(id)
except AttributeError as exc:
logger.warn('update_protobuf_in_db: %s' % repr(exc))
pass
msg_dict = protobuf_to_dict(msg, type_callable_map=type_callable_map)
columns = ', '.join(list(msg_dict.keys()))
placeholders = ':'+', :'.join(list(msg_dict.keys()))
setters = ', '.join('{}=:{}'.format(key, key) for key in msg_dict)
query = 'UPDATE %s SET %s WHERE id=%s' % (table_name, setters, id)
db.session.execute(query, msg_dict)
db.session.commit()
def row_to_protobuf(row, msg, exclude_fields=[]):
for key in list(msg.DESCRIPTOR.fields_by_name.keys()):
if key in exclude_fields:
continue
if row[key] is None:
continue
field = msg.DESCRIPTOR.fields_by_name[key]
if field.type == field.TYPE_UINT64:
setattr(msg, key, int(row[key]))
else:
setattr(msg, key, row[key])
return msg
# FIXME: I should really do this properly...
def get_id(table_name):
while True:
# I think activity id is actually only uint32. On the off chance it's
# int32, stick with 31 bits.
ident = int(random.getrandbits(31))
row = db.session.execute(sqlalchemy.text("SELECT id FROM %s WHERE id = %s" % (table_name, ident))).first()
if not row:
break
return ident
def world_time():
return int((get_utc_time()-1414016075)*1000)
@app.route('/api/clubs/club/can-create', methods=['GET'])
def api_clubs_club_cancreate():
return {"result":False}
@app.route('/api/event-feed', methods=['GET']) #from=1646723199600&limit=25&sport=CYCLING
def api_eventfeed():
eventCount = int(request.args.get('limit'))
events = get_events(eventCount)
json_events = convert_events_to_json(events)
json_data = []
for e in json_events:
json_data.append({"event": e})
return jsonify({"data":json_data})
@app.route('/api/campaign/profile/campaigns', methods=['GET'])
@app.route('/api/notifications', methods=['GET'])
@app.route('/api/announcements/active', methods=['GET'])
def api_empty_arrays():
return jsonify([])
def activity_moving_time(activity):
try:
return (datetime.strptime(activity.end_date, '%y-%m-%dT%H:%M:%SZ') - datetime.strptime(activity.start_date, '%y-%m-%dT%H:%M:%SZ')).total_seconds() * 1000
except:
return 0
def activity_protobuf_to_json(activity):
return {"id":activity.id,"profile":{"id":str(activity.player_id),"firstName":"Youry","lastName":"Pershin","imageSrc":"https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % activity.player_id,"approvalRequired":None}, \
"worldId":activity.f3,"name":activity.name,"sport":str_sport(activity.f29),"startDate":activity.start_date, \
"endDate":activity.end_date,"distanceInMeters":activity.distance, \
"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)}
def select_activities_json(player_id, limit):
ret = []
if limit > 0:
activities = activity_pb2.ActivityList()
# Select every column except 'fit' - despite being a blob python 3 treats it like a utf-8 string and tries to decode it
rows = db.session.execute(sqlalchemy.text("SELECT id, 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, fit_filename, f29, date FROM activity WHERE player_id = %s ORDER BY date desc LIMIT %s" % (str(player_id), limit)))
allow_empty_end_date = True
for row in rows:
activity = activities.activities.add()
row_to_protobuf(row, activity, exclude_fields=['fit'])
if activity.end_date == "":
if allow_empty_end_date:
allow_empty_end_date = False
else:
continue
ret.append(activity_protobuf_to_json(activity))
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')
if feed_type == 'JUST_ME' or feed_type == 'PREVIEW': #what is the difference here?
ret = select_activities_json(current_user.player_id, limit)
else: # todo: FAVORITES, FOLLOWEES
ret = []
return jsonify(ret)
@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'])
def api_clubs():
return {"total":0,"results":[]}
@app.route('/api/clubs/club/list/my-clubs.proto', methods=['GET'])
@app.route('/api/campaign/proto/campaigns', 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, "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, "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'])
def api_users_login():
# Should just return a binary blob rather than build a "proper" response...
response = login_response_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(get_utc_time())
udp_node = response.info.nodes.nodes.add()
if request.remote_addr == '127.0.0.1': # to avoid needing hairpinning
udp_node.ip = "127.0.0.1"
else:
udp_node.ip = server_ip # TCP telemetry server
udp_node.port = 3023
return response.SerializeToString(), 200
def logout_player(player_id):
#Remove player from online when leaving game/world
if player_id in online:
online.pop(player_id)
discord.send_message('%s riders online' % len(online))
if player_id in player_partial_profiles:
player_partial_profiles.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():
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):
events_list = [('Bologna TT', 2843604888, 10),
('Crit City CW', 947394567, 12),
('Crit City CCW', 2875658892, 12),
('Neokyo Crit', 1127056801, 13),
('Watopia Waistband', 1064303857, 6)]
event_id = 1000
cnt = 0
events = events_pb2.Events()
for item in events_list:
event = events.events.add()
event.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
event.id = event_id
event.name = item[0]
event.route_id = item[1] #otherwise new home screen hangs trying to find route in all (even non-existent) courses
event.course_id = item[2]
event.sport = profile_pb2.Sport.CYCLING
event.lateJoinInMinutes = 30
event.eventStart = int(get_time()) * 1000 + 60000
cats = ('?', 'A', 'B', 'C', 'D', 'E', 'F')
for cat in range(1,5):
event_cat = event.category.add()
event_cat.id = event_id + cat
event_cat.registrationStart = event.eventStart - 30000
event_cat.registrationStartWT = world_time()
event_cat.registrationEnd = event.eventStart
event_cat.registrationEndWT = world_time() + 60000
event_cat.lineUpStart = event.eventStart - 15000
event_cat.lineUpEnd = event.eventStart
event_cat.route_id = item[1]
event_cat.startLocation = cat
event_cat.label = cat
event_cat.lateJoinInMinutes = 30
event_cat.name = "Cat.%s" % cats[event_cat.label]
event_cat.description = "#zwiftofficial"
event_cat.course_id = event.course_id
event_cat.paceType = 1
event_cat.fromPaceValue = 1.0
event_cat.toPaceValue = 5.0
event_id += 1000
cnt = cnt + 1
if cnt > limit:
break
return events
@app.route('/api/events/<int:event_id>', methods=['GET'])
def api_events_id(event_id):
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
@app.route('/api/events/subgroups/signup/<int:event_id>', methods=['POST'])
def api_events_subgroups_signup_id(event_id):
return '{"signedUp":true}', 200
@app.route('/api/events/subgroups/register/<int:event_id>', methods=['POST'])
def api_events_subgroups_register_id(event_id):
return '{"registered":true}', 200
@app.route('/api/events/subgroups/entrants/<int:event_id>', methods=['GET'])
def api_events_subgroups_entrants_id(event_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'])
def api_zfiles():
# Don't care about zfiles, but shuts up some errors in Zwift log.
zfile = zfiles_pb2.ZFileProto()
zfile.id = int(random.getrandbits(31))
zfile.folder = "logfiles"
zfile.filename = "yep_took_good_care_of_that_file.txt"
zfile.timestamp = int(get_utc_time())
return zfile.SerializeToString(), 200
# Custom static data
@app.route('/style/<path:filename>')
def custom_style(filename):
return send_from_directory('%s/cdn/style' % SCRIPT_DIR, filename)
# Launcher files are requested over https on macOS
@app.route('/static/web/launcher/<path:filename>')
def static_web_launcher(filename):
return send_from_directory('%s/cdn/static/web/launcher' % SCRIPT_DIR, filename)
@app.route('/static/world_headers/<path:filename>')
def static_world_headers(filename):
return send_from_directory('%s/cdn/static/world_headers' % SCRIPT_DIR, filename)
@app.route('/static/zc/<path:filename>')
def static_zc(filename):
return send_from_directory('%s/cdn/static/zc' % SCRIPT_DIR, filename)
@app.route('/phoneicons/<path:filename>')
def cdn_phoneicons(filename):
return send_from_directory('%s/cdn/phoneicons' % SCRIPT_DIR, filename)
# Probably don't need, haven't investigated
@app.route('/api/zfiles/list', methods=['GET', 'POST'])
def api_zfiles_list():
return '', 200
# Probably don't need, haven't investigated
@app.route('/api/private_event/feed', methods=['GET', 'POST'])
def api_private_event_feed():
return '', 200
# Disable telemetry (shuts up some errors in log)
@app.route('/api/telemetry/config', methods=['GET'])
def api_telemetry_config():
return '{"isEnabled":false}'
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 == 0x7d8c357d):
return "Zwift Carbon"
else:
if (val == -722210337):
return "Zwift TT"
return "---"
def do_api_profiles_me(is_json):
profile_id = current_user.player_id
if MULTIPLAYER:
profile_dir = '%s/%s' % (STORAGE_DIR, profile_id)
else:
# Find first profile.bin if one exists and use it. Multi-profile
# support is deprecated and now unsupported for non-multiplayer mode.
profile_dir = 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):
profile_dir = path
break
if not profile_dir: # no existing profile
profile_dir = "%s/1" % STORAGE_DIR
profile_id = 1
AnonUser.player_id = profile_id
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
return '', 500
profile = profile_pb2.PlayerProfile()
profile_file = '%s/profile.bin' % profile_dir
if not os.path.isfile(profile_file):
profile.id = profile_id
profile.email = current_user.username
profile.first_name = current_user.first_name
profile.last_name = current_user.last_name
else:
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
if MULTIPLAYER:
# For newly added existing profiles, User's player id likely differs from profile's player id.
# If there's existing data in db for this profile, update it for the newly assigned player id.
# XXX: Users can maliciously abuse this by intentionally uploading a profile with another user's current player id.
# However, without it, anyone "upgrading" to multiplayer mode will lose their existing data.
# TODO: need a warning in README that switching to multiplayer mode and back to single player will lose your existing data.
if profile.id != profile_id:
db.session.execute(sqlalchemy.text('UPDATE activity SET player_id = %s WHERE player_id = %s' % (profile_id, profile.id)))
db.session.execute(sqlalchemy.text('UPDATE goal SET player_id = %s WHERE player_id = %s' % (profile_id, profile.id)))
db.session.execute(sqlalchemy.text('UPDATE segment_result SET player_id = %s WHERE player_id = %s' % (profile_id, profile.id)))
db.session.commit()
profile.id = profile_id
elif current_user.player_id != profile.id:
# Update AnonUser's player_id to match
AnonUser.player_id = profile.id
ghosts_enabled[profile.id] = AnonUser.enable_ghosts
if not profile.email:
profile.email = 'user@email.com'
if profile.entitlements:
del profile.entitlements[:]
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": "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % profile.id, "imageSrcLarge": "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % 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'), "location": "",
"socialFacts": jprofileFull.get('socialFacts'), "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:
return profile.SerializeToString(), 200
@app.route('/api/profiles/me', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_profiles_me_bin():
if(request.headers['Source'] == "zwift-companion"):
return do_api_profiles_me(True)
else:
return do_api_profiles_me(False)
@app.route('/api/profiles/me/', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_profiles_me_json():
return do_api_profiles_me(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/search/profiles', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_search_profiles():
query = request.json['query']
rows = db.session.execute(sqlalchemy.text("SELECT player_id,first_name,last_name FROM user WHERE first_name like '%%%s%%' or last_name like '%%%s%%'" % (query, query)))
json_data_list = [];
for row in rows:
player_id = row[0]
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())
json_data_list.append({"id": player_id, "firstName": row[1], "lastName": row[2], "imageSrc": "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id, "imageSrcLarge": "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id, "countryCode": profile.country_code})
return jsonify(json_data_list)
@app.route('/api/profiles/<int:player_id>/statistics', methods=['GET'])
def api_profiles_id_statistics(player_id):
from_dt = request.args.get('startDateTime')
row = db.session.execute(sqlalchemy.text("SELECT sum(Cast ((JulianDay(date) - JulianDay(start_date)) * 24 * 60 As Integer)), sum(distance), sum(calories), sum(total_elevation) FROM activity WHERE player_id = %s and strftime('%%s', start_date) >= strftime('%%s', '%s')" % (str(player_id), 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():
global zc_connect_queue
if not request.stream:
return '', 400
phoneAddress = request.json['phoneAddress']
phonePort = int(request.json['port'])
zc_connect_queue[current_user.player_id] = (phoneAddress, phonePort)
#todo UDP scenario
logger.info("ZCompanion %d reg: %s:%d" % (current_user.player_id, phoneAddress, phonePort))
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']
#profile.large_avatar_url = request.json['imageSrcLarge']
profile.large_avatar_url = "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id
#profile.age = request.json['age']
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_json()
@app.route('/api/profiles/<int:player_id>', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_profiles_id(player_id):
if not request.stream:
return '', 400
if player_id == 0:
# Zwift client 1.0.60239 calls /api/profiles/0 instead of /api/users/logout
logout_player(current_user.player_id)
return '', 204
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)
profile = profile_pb2.PlayerProfile()
profile.ParseFromString(stream)
if MULTIPLAYER:
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 = get_id('activity')
insert_protobuf_into_db('activity', activity)
return '{"id": %ld}' % activity.id, 200
# request.method == 'GET'
activities = activity_pb2.ActivityList()
# Select every column except 'fit' - despite being a blob python 3 treats it like a utf-8 string and tries to decode it
rows = db.session.execute(sqlalchemy.text("SELECT id, 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, fit_filename, f29, date FROM activity WHERE player_id = %s" % str(player_id)))
should_remove = list()
for row in rows:
activity = activities.activities.add()
row_to_protobuf(row, activity, exclude_fields=['fit'])
a = activity
#Remove activities with less than 100m distance
if a.distance < 100:
should_remove.append(a)
for a in should_remove:
db.session.execute(sqlalchemy.text("DELETE FROM activity WHERE id = %s" % a.id))
db.session.commit()
activities.activities.remove(a)
return activities.SerializeToString(), 200
@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)
profile = profile_pb2.PlayerProfile()
if p_id > 10000000:
ghostId = math.floor(p_id / 10000000)
player_id = p_id - ghostId * 10000000
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())
p = profiles.profiles.add()
p.CopyFrom(profile)
p.id = p_id
p.first_name = ''
seconds = (world_time() - global_ghosts[player_id].play.ghosts[ghostId - 1].states[0].worldTime) // 1000
if seconds < 7200: span = '%s minutes' % (seconds // 60)
elif seconds < 172800: span = '%s hours' % (seconds // 3600)
elif seconds < 1209600: span = '%s days' % (seconds // 86400)
elif seconds < 5259492: span = '%s weeks' % (seconds // 604800)
else: span = '%s months' % (seconds // 2629746)
p.last_name = span + ' ago [ghost]'
p.bike_frame = 1456463855 # tron bike
p.country_code = 0
if p.ride_jersey == 3761002195:
p.ride_jersey = 1869390707 # basic 2 jersey
p.bike_frame_colour = 80 # green bike
else:
p.ride_jersey = 3761002195 # basic 4 jersey
p.bike_frame_colour = 125 # blue bike
if p.run_shirt_type == 3344420794:
p.run_shirt_type = 4197967370 # shirt 11
p.run_shorts_type = 3273293920 # shorts 11
else:
p.run_shirt_type = 3344420794 # shirt 10
p.run_shorts_type = 4269451728 # shorts 10
else:
if p_id > 2000000 and p_id < 3000000:
profile_file = '%s/%s/profile.bin' % (PACE_PARTNERS_DIR, i)
elif p_id > 3000000 and p_id < 4000000:
profile_file = '%s/%s/profile.bin' % (BOTS_DIR, i)
else:
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, i)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
profile.id = p_id
p = profiles.profiles.add()
p.CopyFrom(profile)
return profiles.SerializeToString(), 200
def strava_upload(player_id, activity):
try:
from stravalib.client import Client
except ImportError as exc:
logger.warn('stravalib: %s' % repr(exc))
logger.warn("stravalib is not installed. Skipping Strava upload attempt.")
return
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.warn("Failed to read %s. Skipping Strava upload attempt. %s" % (strava_token, repr(exc)))
return
try:
if get_utc_time() > int(expires_at):
refresh_response = strava.refresh_access_token(client_id=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.warn("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.warn("Strava upload failed. No internet? %s" % repr(exc))
def garmin_upload(player_id, activity):
try:
from garmin_uploader.workflow import Workflow
except ImportError as exc:
logger.warn("garmin_uploader is not installed. Skipping Garmin upload attempt. %s" % repr(exc))
return
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
garmin_credentials = '%s/garmin_credentials.txt' % profile_dir
if not os.path.exists(garmin_credentials):
logger.info("garmin_credentials.txt missing, skip Garmin activity update")
return
try:
with open(garmin_credentials, 'r') as f:
if credentials_key is not None:
cipher_suite = Fernet(credentials_key)
ciphered_text = f.read()
unciphered_text = (cipher_suite.decrypt(ciphered_text.encode(encoding='UTF-8')))
unciphered_text = unciphered_text.decode(encoding='UTF-8')
split_credentials = unciphered_text.splitlines()
username = split_credentials[0]
password = split_credentials[1]
else:
username = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
except Exception as exc:
logger.warn("Failed to read %s. Skipping Garmin upload attempt. %s" % (garmin_credentials, repr(exc)))
return
try:
with open('%s/last_activity.fit' % profile_dir, 'wb') as f:
f.write(activity.fit)
except Exception as exc:
logger.warn("Failed to save fit file. Skipping Garmin upload attempt. %s" % repr(exc))
return
try:
w = Workflow(['%s/last_activity.fit' % profile_dir], activity_name=activity.name, username=username, password=password)
w.run()
except Exception as exc:
logger.warn("Garmin upload failed. No internet? %s" % repr(exc))
def runalyze_upload(player_id, activity):
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
runalyze_token = '%s/runalyze_token.txt' % profile_dir
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.warn("Failed to read %s. Skipping Runalyze upload attempt." % (runalyze_token, repr(exc)))
return
try:
with open('%s/last_activity.fit' % profile_dir, 'wb') as f:
f.write(activity.fit)
except Exception as exc:
logger.warn("Failed to save fit file. Skipping Runalyze upload attempt. %s" % repr(exc))
return
try:
r = requests.post("https://runalyze.com/api/v1/activities/uploads",
files={'file': open('%s/last_activity.fit' % profile_dir, "rb")},
headers={"token": runtoken})
logger.info(r.text)
except Exception as exc:
logger.warn("Runalyze upload failed. No internet? %s" % repr(exc))
def zwift_upload(player_id, activity):
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR
zwift_credentials = '%s/zwift_credentials.txt' % profile_dir
if not os.path.exists(zwift_credentials):
logger.info("zwift_credentials.txt missing, skip Zwift activity update")
return
if not os.path.exists(SERVER_IP_FILE):
logger.info("server_ip.txt missing, skip Zwift activity update")
return
try:
with open(zwift_credentials, 'r') as f:
if credentials_key is not None:
cipher_suite = Fernet(credentials_key)
ciphered_text = f.read()
unciphered_text = (cipher_suite.decrypt(ciphered_text.encode(encoding='UTF-8')))
unciphered_text = unciphered_text.decode(encoding='UTF-8')
split_credentials = unciphered_text.splitlines()
username = split_credentials[0]
password = split_credentials[1]
else:
username = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
except Exception as exc:
logger.warn("Failed to read %s. Skipping Zwift upload attempt. %s" % (zwift_credentials, repr(exc)))
return
try:
session = requests.session()
try:
activity = activity_pb2.Activity()
access_token, refresh_token = online_sync.login(session, username, password)
activity.player_id = online_sync.get_player_id(session, access_token)
res = online_sync.upload_activity(session, access_token, activity)
if res == 200:
logger.info("Zwift activity upload succesfull")
else:
logger.warn("Zwift activity upload failed:%s:" %res)
online_sync.logout(session, refresh_token)
except Exception as exc:
logger.warn("Error uploading activity to Zwift Server. %s" % repr(exc))
except Exception as exc:
logger.warn("Zwift upload failed. No internet? %s" % repr(exc))
# With 64 bit ids Zwift can pass negative numbers due to overflow, which the flask int
# converter does not handle so it's a string argument
@app.route('/api/profiles/<int:player_id>/activities/<string:activity_id>', methods=['PUT', 'DELETE'])
@jwt_to_session_cookie
@login_required
def api_profiles_activities_id(player_id, activity_id):
if not request.stream:
return '', 400
if current_user.player_id != player_id:
return '', 401
if request.method == 'DELETE':
db.session.execute(sqlalchemy.text("DELETE FROM activity WHERE id = %s" % activity_id))
db.session.commit()
return 'true', 200
activity_id = int(activity_id) & 0xffffffffffffffff
activity = activity_pb2.Activity()
activity.ParseFromString(request.stream.read())
update_protobuf_in_db('activity', activity, activity_id)
response = '{"id":%s}' % activity_id
if request.args.get('upload-to-strava') != 'true':
return response, 200
player_id = current_user.player_id
if current_user.enable_ghosts:
try:
save_ghost(activity.name, player_id)
except Exception as exc:
logger.warn('save_ghost: %s' % repr(exc))
pass
# For using with upload_activity
with open('%s/%s/last_activity.bin' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(activity.SerializeToString())
# Unconditionally *try* and upload to strava and garmin since profile may
# not be properly linked to strava/garmin (i.e. no 'upload-to-strava' call
# will occur with these profiles).
strava_upload(player_id, activity)
garmin_upload(player_id, activity)
runalyze_upload(player_id, activity)
zwift_upload(player_id, activity)
return response, 200
@app.route('/api/profiles/<int:recieving_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(recieving_player_id):
sending_player_id = request.json['profileId']
profile = get_partial_profile(sending_player_id)
if not profile == 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_RIDE_ON
player_update.world_time_born = world_time()
player_update.world_time_expire = player_update.world_time_born + 9890
player_update.timestamp = int(get_utc_time() * 1000000)
ride_on = udp_node_msgs_pb2.RideOn()
ride_on.player_id = int(sending_player_id)
ride_on.to_player_id = int(recieving_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()
if not recieving_player_id in player_update_queue:
player_update_queue[recieving_player_id] = list()
player_update_queue[recieving_player_id].append(player_update.SerializeToString())
receiver = get_partial_profile(recieving_player_id)
message = 'Ride on ' + receiver.first_name + ' ' + receiver.last_name + '!'
discord.send_message(message, sending_player_id)
return '{}', 200
@app.route('/api/private_event/entitlement', methods=['GET'])
def api_empty_obj():
return '{}', 200
@app.route('/api/profiles/<int:player_id>/campaigns/otm2020', methods=['GET'])
@app.route('/api/profiles/<int:player_id>/followees', methods=['GET'])
def api_profiles_followees(player_id):
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 unix_time_millis(dt):
return int(get_seconds_from_date_time(dt)*1000)
def fill_in_goal_progress(goal, player_id):
local_now = datetime.datetime.now()
utc_offset = datetime.datetime.fromtimestamp(0) - datetime.datetime.utcfromtimestamp(0)
if goal.periodicity == 0: # weekly
first_dt, last_dt = get_week_range(local_now)
else: # monthly
first_dt, last_dt = get_month_range(local_now)
common_sql = ("""FROM activity
WHERE player_id = %s AND f29 = %s
AND strftime('%s', start_date) >= strftime('%s', '%s')
AND strftime('%s', start_date) <= strftime('%s', '%s')""" %
(player_id, goal.f3, '%s', '%s', first_dt - utc_offset, '%s', '%s', last_dt - utc_offset))
if goal.type == 0: # distance
distance = db.session.execute(sqlalchemy.text('SELECT SUM(distance) %s' % common_sql)).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)).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):
local_now = datetime.datetime.now()
utc_offset = int((datetime.datetime.fromtimestamp(0) - datetime.datetime.utcfromtimestamp(0)).total_seconds())
if goal.periodicity == 0: # weekly
goal.period_end_date = unix_time_millis(get_week_range(local_now)[1]) - utc_offset
else: # monthly
goal.period_end_date = unix_time_millis(get_month_range(local_now)[1]) - utc_offset
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):
sec = int(ts/1000)
ms = ts % 1000
return datetime.datetime.utcfromtimestamp(sec).strftime('%Y-%m-%dT%H:%M:%S.') + str(ms).zfill(3) + "+0000"
def goalProtobufToJson(goal):
return {"id":goal.id,"profileId":goal.player_id,"sport":str_sport(goal.f3),"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(goal.created_on),
"periodEndDate":str_timestamp(goal.period_end_date),"status":int(goal.f13),"timezone":""}
def goalJsonToProtobuf(json_goal):
goal = goal_pb2.Goal()
goal.f3 = 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.f13 = 0 #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:
rows = db.session.execute(sqlalchemy.text("SELECT * FROM goal WHERE player_id = %s LIMIT %s" % (player_id, limit)))
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)
now = get_utc_date_time()
if end_dt < now:
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.id = get_id('goal')
now = get_utc_date_time()
goal.created_on = unix_time_millis(now)
set_goal_end_date_now(goal)
fill_in_goal_progress(goal, player_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/<string: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
goal_id = int(goal_id) & 0xffffffffffffffff
db.session.execute(sqlalchemy.text("DELETE FROM goal WHERE id = %s" % 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()
if request.remote_addr == '127.0.0.1': # to avoid needing hairpinning
info.ip = "127.0.0.1"
else:
info.ip = server_ip
info.port = 3023
return infos.SerializeToString(), 200
def add_player_to_world(player, course_world, is_pace_partner):
course_id = get_course(player)
if course_id in course_world.keys():
partial_profile = get_partial_profile(player.id)
if not partial_profile == None:
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
else:
online_player = course_world[course_id].others.add()
online_player.id = player.id
online_player.firstName = partial_profile.first_name
online_player.lastName = 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):
courses = courses_lookup.keys()
# Android client also requests a JSON version
if request.headers['Accept'] == 'application/json':
if request.content_type == 'application/x-protobuf-lite':
#chat_message = tcp_node_msgs_pb2.SocialPlayerAction()
#serializedMessage = None
try:
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.ParseFromString(request.data)
#chat_message.ParseFromString(request.data[6:])
#serializedMessage = chat_message.SerializeToString()
except Exception as exc:
logger.warn('player_update_parse: %s' % repr(exc))
#Not able to decode as playerupdate, send dummy response
world = { 'currentDateTime': int(get_utc_time()),
'currentWorldTime': world_time(),
'friendsInWorld': [],
'mapId': 1, #maybe, 13 for watopia?
'name': 'Public Watopia',
'playerCount': 0,
'worldId': udp_node_msgs_pb2.ZofflineConstants.RealmID
}
if server_realm:
world['worldId'] = server_realm
return jsonify(world)
else:
return jsonify([ world ])
#PlayerUpdate
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(get_utc_time()*1000000)
for recieving_player_id in online.keys():
should_receive = False
if player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SPA or player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SR:
recieving_player = online[recieving_player_id]
#Chat message
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)
sending_player_id = chat_message.player_id
if sending_player_id in online:
sending_player = online[sending_player_id]
#Check that players are on same course and close to each other
if is_nearby(sending_player, recieving_player):
should_receive = True
#Segment complete
else:
segment_complete = segment_result_pb2.SegmentResult()
segment_complete.ParseFromString(player_update.payload)
sending_player_id = segment_complete.player_id
if sending_player_id in online:
sending_player = online[sending_player_id]
#Check that players are on same course
if get_course(sending_player) == get_course(recieving_player) or recieving_player.watchingRiderId == sending_player_id:
should_receive = True
#Other PlayerUpdate, send to all
else:
should_receive = True
if should_receive:
if not recieving_player_id in player_update_queue:
player_update_queue[recieving_player_id] = list()
player_update_queue[recieving_player_id].append(player_update.SerializeToString())
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)
discord.send_message(chat_message.message, chat_message.player_id)
return '{}', 200
else: # protobuf request
worlds = world_pb2.DropInWorldList()
world = None
course_world = {}
for course in courses:
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(get_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, False)
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, 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, False)
if server_realm:
world.id = server_realm
return world.SerializeToString()
else:
return worlds.SerializeToString()
@app.route('/relay/worlds', methods=['GET'])
@app.route('/relay/dropin', methods=['GET']) #zwift::protobuf::DropInWorldList
def relay_worlds():
return relay_worlds_generic()
def iterableToJson(it):
if it == None:
return None
ret = []
for i in it:
ret.append(i)
return ret
def eventProtobufToJson(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":event_cat.registrationStart,"registrationEnd":event_cat.registrationEnd,"lineUpStart":event_cat.lineUpStart, \
"lineUpEnd":event_cat.lineUpEnd,"eventSubgroupStart":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":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":str(event.eventType), \
"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 = eventProtobufToJson(e)
json_events.append(json_event)
return json_events
#todo: followingCount=3&pendingEventInviteCount=50&acceptedEventInviteCount=3&playerTotal=true&playerSport=all&eventSport=CYCLING&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(current_user.player_id, activityCount)
eventCount = int(request.args.get('eventCount'))
events = get_events(eventCount)
json_events = convert_events_to_json(events)
return jsonify({"events":json_events,"goals":json_goals,"activities":json_activities,"pendingPrivateEventFeed":[],"acceptedPrivateEventFeed":[],"hasFolloweesToRideOn":False, \
"worldName":"MAKURIISLANDS","playerCount":0,"followingPlayerCount":0,"followingPlayers":[]})
@app.route('/relay/worlds/<int:server_realm>', methods=['GET'])
@app.route('/relay/worlds/<int:server_realm>/', methods=['GET'])
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 ""
@app.route('/relay/worlds/<int:server_realm>/my-hash-seeds', methods=['GET'])
def relay_worlds_my_hash_seeds(server_realm):
return '[{"expiryDate":196859639979,"seed1":-733221030,"seed2":-2142448243},{"expiryDate":196860425476,"seed1":1528095532,"seed2":-2078218472},{"expiryDate":196862212008,"seed1":1794747796,"seed2":-1901929955},{"expiryDate":196862637148,"seed1":-1411883466,"seed2":1171710140},{"expiryDate":196863874267,"seed1":670195825,"seed2":-317830991}]'
@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
# XXX: attributes have not been thoroughly investigated
@app.route('/relay/worlds/<int:server_realm>/attributes', methods=['POST'])
def relay_worlds_id_attributes(server_realm):
# NOTE: This was previously a protobuf message in Zwift client, but later changed.
# attribs = world_pb2.WorldAttributes()
# attribs.world_time = world_time()
# return attribs.SerializeToString(), 200
return relay_worlds_generic(server_realm)
@app.route('/relay/worlds/attributes', methods=['POST'])
def relay_worlds_attributes():
# PlayerUpdate was previously a json request handled in relay_worlds_generic()
# now it's protobuf posted to this new route (at least in Windows client)
player_update = udp_node_msgs_pb2.WorldAttribute()
try:
player_update.ParseFromString(request.data)
except Exception as exc:
logger.warn('player_update_parse: %s' % repr(exc))
return '', 422
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(get_utc_time() * 1000000)
for receiving_player_id in online.keys():
should_receive = False
if player_update.wa_type in [udp_node_msgs_pb2.WA_TYPE.WAT_SPA, udp_node_msgs_pb2.WA_TYPE.WAT_SR]:
receiving_player = online[receiving_player_id]
# Chat message
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)
sending_player_id = chat_message.player_id
if sending_player_id in online:
sending_player = online[sending_player_id]
if is_nearby(sending_player, receiving_player):
should_receive = True
# Segment complete
else:
segment_complete = segment_result_pb2.SegmentResult()
segment_complete.ParseFromString(player_update.payload)
sending_player_id = segment_complete.player_id
if sending_player_id in online:
sending_player = online[sending_player_id]
if get_course(sending_player) == get_course(receiving_player) or receiving_player.watchingRiderId == sending_player_id:
should_receive = True
# Other PlayerUpdate, send to all
else:
should_receive = True
if should_receive:
if not receiving_player_id in player_update_queue:
player_update_queue[receiving_player_id] = list()
player_update_queue[receiving_player_id].append(player_update.SerializeToString())
# If it's a chat message, send to Discord
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)
discord.send_message(chat_message.message, chat_message.player_id)
return '', 201
@app.route('/relay/periodic-info', methods=['GET'])
def relay_periodic_info():
infos = periodic_info_pb2.PeriodicInfos()
info = infos.infos.add()
if request.remote_addr == '127.0.0.1': # to avoid needing hairpinning
info.game_server_ip = "127.0.0.1"
else:
info.game_server_ip = server_ip
info.f2 = 3022
info.f3 = 10
info.f4 = 60
info.f5 = 30
info.f6 = 3
return infos.SerializeToString(), 200
def add_segment_results(segment_id, player_id, only_best, from_date, to_date, results):
where_stmt = ("WHERE segment_id = '%s'" % segment_id)
rows = None
if player_id:
where_stmt += (" AND player_id = '%s'" % player_id)
if from_date:
where_stmt += (" AND strftime('%s', finish_time_str) > strftime('%s', '%s')" % ('%s', '%s', from_date))
if to_date:
where_stmt += (" AND strftime('%s', finish_time_str) < strftime('%s', '%s')" % ('%s', '%s', to_date))
if only_best:
#Only include results from max 1 hour ago
where_stmt += (" AND world_time > '%s'" % (world_time()-(60*60*1000)))
rows = db.session.execute(sqlalchemy.text("""SELECT s1.* FROM segment_result s1
JOIN (SELECT s.player_id, MIN(Cast(s.elapsed_ms AS INTEGER)) 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 = CAST(s1.elapsed_ms AS INTEGER)
GROUP BY s1.player_id, s1.elapsed_ms
ORDER BY CAST(s1.elapsed_ms AS INTEGER)
LIMIT 1000""" % where_stmt))
else:
rows = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s" % where_stmt))
for row in rows:
result = results.segment_results.add()
row_to_protobuf(row, result, ['f3', 'f4', 'segment_id', 'event_subgroup_id', 'finish_time_str', 'f14', 'f17', 'f18'])
def handle_segment_results(request):
if request.method == 'POST':
if not request.stream:
return '', 400
result = segment_result_pb2.SegmentResult()
result.ParseFromString(request.stream.read())
result.id = get_id('segment_result')
result.world_time = world_time()
result.finish_time_str = get_utc_date_time().strftime("%Y-%m-%dT%H:%M:%SZ")
result.f20 = 0
insert_protobuf_into_db('segment_result', result)
return {"id": result.id}
# request.method == GET
# world_id = int(request.args.get('world_id'))
player_id = request.args.get('player_id')
# full = request.args.get('full') == 'true'
# Require segment_id
if not request.args.get('segment_id'):
return '', 422
segment_id = int(request.args.get('segment_id')) & 0xffffffffffffffff
only_best = request.args.get('only-best') == 'true'
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
if player_id:
#Add players results
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results)
else:
#Top 100 results, player_id = None
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results)
return results.SerializeToString(), 200
@app.route('/relay/segment-results', methods=['GET'])
def relay_segment_results():
return handle_segment_results(request)
@app.route('/api/segment-results', methods=['GET', 'POST'])
@jwt_to_session_cookie
@login_required
def api_segment_results():
#Checks that online player has values for ghosts and player_id
player_id = current_user.player_id
if request.method == 'POST' and player_id != current_user.player_id:
return '', 401
return handle_segment_results(request)
@app.route('/live-segment-results-service/leaders', methods=['GET'])
def live_segment_results_service_leaders():
return '', 200
@app.route('/relay/worlds/<int:server_realm>/leave', methods=['POST'])
def relay_worlds_leave(server_realm):
return '{"worldtime":%ld}' % world_time()
@app.teardown_request
def teardown_request(exception):
db.session.close()
if exception != None:
print('Exception: %s' % exception)
def move_old_profile():
# Before multi profile support only a single profile located in storage
# named profile.bin existed. If upgrading from this, convert to
# multi profile file structure.
profile_file = '%s/profile.bin' % STORAGE_DIR
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile = profile_pb2.PlayerProfile()
profile.ParseFromString(fd.read())
profile_dir = '%s/%s' % (STORAGE_DIR, profile.id)
try:
if not os.path.isdir(profile_dir):
os.makedirs(profile_dir)
except IOError as e:
logger.error("failed to create profile dir (%s): %s", profile_dir, str(e))
sys.exit(1)
os.rename(profile_file, '%s/profile.bin' % profile_dir)
strava_file = '%s/strava_token.txt' % STORAGE_DIR
if os.path.isfile(strava_file):
os.rename(strava_file, '%s/strava_token.txt' % profile_dir)
def init_database():
if not os.path.exists(DATABASE_PATH) or not os.path.getsize(DATABASE_PATH):
# Create a new database
with open(DATABASE_INIT_SQL, 'r') as f:
sql_statements = f.read().split('\n\n')
for sql_statement in sql_statements:
db.session.execute(sql_statement)
db.session.commit()
db.session.execute('INSERT INTO version VALUES (:ver)', {'ver': DATABASE_CUR_VER})
db.session.commit()
return
# 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
version = db.session.execute('SELECT version FROM version').first()[0]
if version == DATABASE_CUR_VER:
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(get_utc_time())))
except:
try: # Fall back to a temporary dir
copyfile(DATABASE_PATH, "%s/zwift-offline.db.v%s.%d.bak" % (tempfile.gettempdir(), version, int(get_utc_time())))
except Exception as exc:
logging.warn("Failed to create a zoffline database backup prior to upgrading it. %s" % repr(exc))
if version < 1:
# Adjust old world_time values in segment results to new rough estimate of Zwift's
logging.info("Upgrading zwift-offline.db to version 2")
db.session.execute('UPDATE segment_result SET world_time = world_time-1414016075000')
db.session.execute('UPDATE version SET version = 2')
db.session.commit()
if version == 1:
logging.info("Upgrading zwift-offline.db to version 2")
db.session.execute('UPDATE segment_result SET world_time = cast(world_time/64.4131403573055-1414016075 as int)*1000')
db.session.execute('UPDATE version SET version = 2')
db.session.commit()
def check_columns():
rows = db.session.execute(sqlalchemy.text("PRAGMA table_info(user)"))
should_have_columns = User.metadata.tables['user'].columns
current_columns = list()
for row in rows:
current_columns.append(row[1])
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 user ADD %s %s %s%s;" % (column.name, str(column.type), nulltext, defaulttext)))
db.session.commit()
def send_server_back_online_message():
time.sleep(30)
message = "We're back online. Ride on!"
send_message_to_all_online(message)
discord.send_message(message)
@app.before_first_request
def before_first_request():
move_old_profile()
init_database()
db.create_all(app=app)
db.session.commit() # in case create_all created a table
check_columns()
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="", enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(),
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 check_password_hash(user.pass_hash, password):
login_user(user, remember=True)
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}))
return jsonify(fake_jwt_with_session_cookie(token['session_cookie']))
else: # android login
current_user.enable_ghosts = user.enable_ghosts
ghosts_enabled[current_user.player_id] = current_user.enable_ghosts
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:
AnonUser.enable_ghosts = os.path.exists(ENABLEGHOSTS_FILE)
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 the game client to logout (anyway jwt.decode would fail)
return '', 204
@app.route("/start-zwift" , methods=['POST'])
@login_required
def start_zwift():
if MULTIPLAYER:
current_user.enable_ghosts = 'enableghosts' in request.form.keys()
ghosts_enabled[current_user.player_id] = current_user.enable_ghosts
else:
AnonUser.enable_ghosts = 'enableghosts' in request.form.keys()
if AnonUser.enable_ghosts:
if not os.path.exists(ENABLEGHOSTS_FILE):
f = open(ENABLEGHOSTS_FILE, 'w')
f.close()
elif os.path.exists(ENABLEGHOSTS_FILE):
os.remove(ENABLEGHOSTS_FILE)
db.session.commit()
selected_map = request.form['map']
if selected_map == 'CALENDAR':
return redirect("/ride", 302)
else:
response = make_response(redirect("http://cdn.zwift.com/map_override", 302))
response.set_cookie('selected_map', selected_map, domain=".zwift.com")
if MULTIPLAYER:
response.set_cookie('remember_token', request.cookies['remember_token'], domain=".zwift.com")
return response
# Called by Mac, but not Windows
@app.route('/auth/realms/zwift/tokens/access/codes', methods=['POST'])
def auth_realms_zwift_tokens_access_codes():
if MULTIPLAYER:
if "code" in request.form:
remember_token = unquote(request.form['code'])
return fake_jwt_with_session_cookie(remember_token), 200
elif "refresh_token" in request.form:
token = jwt.decode(request.form['refresh_token'], options=({'verify_signature': False, 'verify_aud': False}))
return fake_jwt_with_session_cookie(token['session_cookie'])
remember_token = unquote(request.form['code'])
return fake_jwt_with_session_cookie(remember_token), 200
else:
return FAKE_JWT, 200
@app.route('/experimentation/v1/variant', methods=['POST'])
def experimentation_v1_variant():
variants = variants_pb2.FeatureResponse()
with open(os.path.join(SCRIPT_DIR, "variants.txt")) as f:
Parse(f.read(), variants)
return variants.SerializeToString(), 200
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())
return profile.saved_game[0x110:0x110+0x40] #0x110 = accessories1_100 + 2x8-byte headers
@app.route('/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())
with open(achievements_file, 'rb') as f:
return f.read(), 200
@app.route('/achievement/unlock', methods=['POST'])
@jwt_to_session_cookie
@login_required
def achievement_unlock():
if not request.stream:
return '', 400
with open(os.path.join(STORAGE_DIR, str(current_user.player_id), 'achievements.bin'), 'wb') as f:
f.write(request.stream.read())
return '', 202
def run_standalone(passed_online, passed_global_pace_partners, passed_global_bots, passed_global_ghosts, passed_ghosts_enabled, passed_save_ghost, passed_player_update_queue, passed_discord):
global online
global global_pace_partners
global global_bots
global global_ghosts
global ghosts_enabled
global save_ghost
global player_update_queue
global discord
global login_manager
online = passed_online
global_pace_partners = passed_global_pace_partners
global_bots = passed_global_bots
global_ghosts = passed_global_ghosts
ghosts_enabled = passed_ghosts_enabled
save_ghost = passed_save_ghost
player_update_queue = passed_player_update_queue
discord = passed_discord
login_manager = LoginManager()
login_manager.login_view = 'login'
login_manager.session_protection = None
if not MULTIPLAYER:
login_manager.anonymous_user = AnonUser
login_manager.init_app(app)
@login_manager.user_loader
def load_user(uid):
return User.query.get(int(uid))
send_message_thread = threading.Thread(target=send_server_back_online_message)
send_message_thread.start()
logger.info("Server is running.")
server = WSGIServer(('0.0.0.0', 443), app, certfile='%s/cert-zwift-com.pem' % SSL_DIR, keyfile='%s/key-zwift-com.pem' % SSL_DIR, log=logger)
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='0.0.0.0') # debug=True, use_reload=False)
if __name__ == "__main__":
run_standalone({}, {}, None)