#!/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 import base64 import uuid import jwt import sqlalchemy import fitdecode from copy import deepcopy from functools import wraps from io import BytesIO from shutil import copyfile, rmtree from logging.handlers import RotatingFileHandler from urllib.parse import quote 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.json_format import MessageToDict, Parse from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from Crypto.Cipher import AES from Crypto.Random import get_random_bytes from collections import deque from itertools import islice sys.path.append(os.path.join(sys.path[0], 'protobuf')) # otherwise import in .proto does not work import udp_node_msgs_pb2 import tcp_node_msgs_pb2 import activity_pb2 import goal_pb2 import login_pb2 import per_session_info_pb2 import profile_pb2 import segment_result_pb2 import route_result_pb2 import world_pb2 import zfiles_pb2 import hash_seeds_pb2 import events_pb2 import variants_pb2 import playback_pb2 import online_sync logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) logger = logging.getLogger('zoffline') logger.setLevel(logging.DEBUG) logging.getLogger('sqlalchemy.engine').setLevel(logging.WARN) if getattr(sys, 'frozen', False): # If we're running as a pyinstaller bundle SCRIPT_DIR = sys._MEIPASS STORAGE_DIR = "%s/storage" % os.path.dirname(sys.executable) LOGS_DIR = "%s/logs" % os.path.dirname(sys.executable) else: SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) STORAGE_DIR = "%s/storage" % SCRIPT_DIR LOGS_DIR = "%s/logs" % SCRIPT_DIR 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_PATH = "%s/zwift-offline.db" % STORAGE_DIR DATABASE_CUR_VER = 3 # 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: import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(('10.254.254.254', 1)) server_ip = s.getsockname()[0] except: server_ip = '127.0.0.1' finally: s.close() logger.info("server-ip.txt not found, using %s", server_ip) SECRET_KEY_FILE = "%s/secret-key.txt" % STORAGE_DIR ENABLEGHOSTS_FILE = "%s/enable_ghosts.txt" % STORAGE_DIR MULTIPLAYER = os.path.exists("%s/multiplayer.txt" % STORAGE_DIR) if MULTIPLAYER: 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) CREDENTIALS_KEY_FILE = "%s/credentials-key.bin" % STORAGE_DIR if not os.path.exists(CREDENTIALS_KEY_FILE): with open(CREDENTIALS_KEY_FILE, 'wb') as f: f.write(get_random_bytes(32)) with open(CREDENTIALS_KEY_FILE, 'rb') as f: credentials_key = f.read() import warnings with warnings.catch_warnings(): from stravalib.client import Client STRAVA_CLIENT_ID = '28117' STRAVA_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() db.init_app(app) online = {} zc_connect_queue = {} player_partial_profiles = {} restarting = False restarting_in_minutes = 0 reload_pacer_bots = False with open(os.path.join(SCRIPT_DIR, "data", "climbs.txt")) as f: CLIMBS = json.load(f) with open(os.path.join(SCRIPT_DIR, "data", "game_dictionary.txt")) as f: GD = json.load(f, object_hook=lambda d: {int(k) if k.lstrip('-').isdigit() else k: v for k, v in d.items()}) class User(UserMixin, db.Model): player_id = db.Column(db.Integer, primary_key=True) username = db.Column(db.Text, unique=True, nullable=False) first_name = db.Column(db.Text, nullable=False) last_name = db.Column(db.Text, nullable=False) pass_hash = db.Column(db.Text, nullable=False) enable_ghosts = db.Column(db.Integer, nullable=False, default=1) is_admin = db.Column(db.Integer, nullable=False, default=0) remember = db.Column(db.Integer, nullable=False, default=0) def __repr__(self): return self.username def get_id(self): return self.player_id def get_token(self): dt = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30) return jwt.encode({'user': self.player_id, 'exp': dt}, app.config['SECRET_KEY'], algorithm='HS256') @staticmethod def verify_token(token): try: data = jwt.decode(token, app.config['SECRET_KEY'], algorithms='HS256') except: return None id = data.get('user') if id: return db.session.get(User, id) return None class AnonUser(User, AnonymousUserMixin, db.Model): username = "zoffline" first_name = "z" last_name = "offline" enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE) def is_authenticated(self): return True class Activity(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer) course_id = db.Column(db.Integer) name = db.Column(db.Text) f5 = db.Column(db.Integer) privateActivity = db.Column(db.Integer) start_date = db.Column(db.Text) end_date = db.Column(db.Text) distanceInMeters = db.Column(db.Float) avg_heart_rate = db.Column(db.Float) max_heart_rate = db.Column(db.Float) avg_watts = db.Column(db.Float) max_watts = db.Column(db.Float) avg_cadence = db.Column(db.Float) max_cadence = db.Column(db.Float) avg_speed = db.Column(db.Float) max_speed = db.Column(db.Float) calories = db.Column(db.Float) total_elevation = db.Column(db.Float) strava_upload_id = db.Column(db.Integer) strava_activity_id = db.Column(db.Integer) f22 = db.Column(db.Text) f23 = db.Column(db.Integer) fit = db.Column(db.LargeBinary) fit_filename = db.Column(db.Text) subgroupId = db.Column(db.Integer) workoutHash = db.Column(db.Integer) progressPercentage = db.Column(db.Float) sport = db.Column(db.Integer) date = db.Column(db.Text) act_f32 = db.Column(db.Float) act_f33 = db.Column(db.Text) act_f34 = db.Column(db.Text) privacy = db.Column(db.Integer) fitness_privacy = db.Column(db.Integer) club_name = db.Column(db.Text) movingTimeInMs = db.Column(db.Integer) class SegmentResult(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer) server_realm = db.Column(db.Integer) course_id = db.Column(db.Integer) segment_id = db.Column(db.Integer) event_subgroup_id = db.Column(db.Integer) first_name = db.Column(db.Text) last_name = db.Column(db.Text) world_time = db.Column(db.Integer) finish_time_str = db.Column(db.Text) elapsed_ms = db.Column(db.Integer) power_source_model = db.Column(db.Integer) weight_in_grams = db.Column(db.Integer) f14 = db.Column(db.Integer) avg_power = db.Column(db.Integer) is_male = db.Column(db.Integer) time = db.Column(db.Text) player_type = db.Column(db.Integer) avg_hr = db.Column(db.Integer) sport = db.Column(db.Integer) activity_id = db.Column(db.Integer) f22 = db.Column(db.Integer) f23 = db.Column(db.Text) class RouteResult(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer) server_realm = db.Column(db.Integer) map_id = db.Column(db.Integer) route_hash = db.Column(db.Integer) event_id = db.Column(db.Integer) world_time = db.Column(db.Integer) elapsed_ms = db.Column(db.Integer) power_type = db.Column(db.Integer) weight_in_grams = db.Column(db.Integer) height_in_centimeters = db.Column(db.Integer) ftp = db.Column(db.Integer) avg_power = db.Column(db.Integer) max_power = db.Column(db.Integer) avg_hr = db.Column(db.Integer) max_hr = db.Column(db.Integer) calories = db.Column(db.Integer) gender = db.Column(db.Integer) player_type = db.Column(db.Integer) sport = db.Column(db.Integer) activity_id = db.Column(db.Integer) steering = db.Column(db.Integer) hr_monitor = db.Column(db.Text) power_meter = db.Column(db.Text) controllable = db.Column(db.Text) cadence_sensor = db.Column(db.Text) class Goal(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer) sport = db.Column(db.Integer) name = db.Column(db.Text) type = db.Column(db.Integer) periodicity = db.Column(db.Integer) target_distance = db.Column(db.Float) target_duration = db.Column(db.Float) actual_distance = db.Column(db.Float) actual_duration = db.Column(db.Float) created_on = db.Column(db.Integer) period_end_date = db.Column(db.Integer) status = db.Column(db.Integer) timezone = db.Column(db.Text) class Playback(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer, nullable=False) uuid = db.Column(db.Text, nullable=False) segment_id = db.Column(db.Integer, nullable=False) time = db.Column(db.Float, nullable=False) world_time = db.Column(db.Integer, nullable=False) type = db.Column(db.Integer) class Zfile(db.Model): id = db.Column(db.Integer, primary_key=True) folder = db.Column(db.Text, nullable=False) filename = db.Column(db.Text, nullable=False) timestamp = db.Column(db.Integer, nullable=False) player_id = db.Column(db.Integer, nullable=False) class PrivateEvent(db.Model): # cached in glb_private_events id = db.Column(db.Integer, primary_key=True) json = db.Column(db.Text, nullable=False) class Notification(db.Model): id = db.Column(db.Integer, primary_key=True) event_id = db.Column(db.Integer, nullable=False) player_id = db.Column(db.Integer, nullable=False) json = db.Column(db.Text, nullable=False) class ActivityFile(db.Model): id = db.Column(db.Integer, primary_key=True) activity_id = db.Column(db.Integer, nullable=False) full = db.Column(db.Integer, nullable=False) class PowerCurve(db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer, nullable=False) time = db.Column(db.Text, nullable=False) power = db.Column(db.Integer, nullable=False) power_wkg = db.Column(db.Float, nullable=False) timestamp = db.Column(db.Integer, nullable=False) class Version(db.Model): version = db.Column(db.Integer, primary_key=True) class Relay: def __init__(self, key = b''): self.ri = 0 self.tcp_ci = 0 self.udp_ci = 0 self.tcp_r_sn = 0 self.tcp_t_sn = 0 self.udp_r_sn = 0 self.udp_t_sn = 0 self.key = key class PartialProfile: player_id = 0 first_name = '' last_name = '' country_code = 0 route = 0 player_type = 'NORMAL' male = True weight_in_grams = 0 imageSrc = '' def to_json(self): return {"countryCode": self.country_code, "enrolledZwiftAcademy": False, #don't need "firstName": self.first_name, "id": self.player_id, "imageSrc": self.imageSrc, "lastName": self.last_name, "male": self.male, "playerType": self.player_type } class Online: total = 0 richmond = 0 watopia = 0 london = 0 makuriislands = 0 newyork = 0 innsbruck = 0 yorkshire = 0 france = 0 paris = 0 scotland = 0 courses_lookup = { 2: 'Richmond', 4: 'Unknown', # event specific? 6: 'Watopia', 7: 'London', 8: 'New York', 9: 'Innsbruck', 10: 'Bologna', # event specific 11: 'Yorkshire', 12: 'Crit City', # event specific 13: 'Makuri Islands', 14: 'France', 15: 'Paris', 16: 'Gravel Mountain', # event specific 17: 'Scotland' } def get_online(): online_in_region = Online() for p_id in online: player_state = online[p_id] course = get_course(player_state) course_name = courses_lookup[course] if course_name == 'Richmond': online_in_region.richmond += 1 elif course_name == 'Watopia': online_in_region.watopia += 1 elif course_name == 'London': online_in_region.london += 1 elif course_name == 'Makuri Islands': online_in_region.makuriislands += 1 elif course_name == 'New York': online_in_region.newyork += 1 elif course_name == 'Innsbruck': online_in_region.innsbruck += 1 elif course_name == 'Yorkshire': online_in_region.yorkshire += 1 elif course_name == 'France': online_in_region.france += 1 elif course_name == 'Paris': online_in_region.paris += 1 elif course_name == 'Scotland': online_in_region.scotland += 1 online_in_region.total += 1 return online_in_region def toSigned(n, byte_count): return int.from_bytes(n.to_bytes(byte_count, 'little'), 'little', signed=True) def imageSrc(player_id): if os.path.isfile(os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg')): return "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id else: return None def get_partial_profile(player_id): if not player_id in player_partial_profiles: partial_profile = PartialProfile() partial_profile.player_id = player_id if player_id in global_pace_partners.keys(): profile = global_pace_partners[player_id].profile elif player_id in global_bots.keys(): profile = global_bots[player_id].profile elif player_id > 10000000: g_id = math.floor(player_id / 10000000) p_id = player_id - g_id * 10000000 partial_profile.first_name = '' partial_profile.last_name = time_since(global_ghosts[p_id].play[g_id-1].date) return partial_profile else: #Read from disk profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id) if os.path.isfile(profile_file): with open(profile_file, 'rb') as fd: profile = profile_pb2.PlayerProfile() profile.ParseFromString(fd.read()) else: user = User.query.filter_by(player_id=player_id).first() partial_profile.first_name = user.first_name partial_profile.last_name = user.last_name return partial_profile partial_profile.imageSrc = imageSrc(player_id) partial_profile.first_name = profile.first_name partial_profile.last_name = profile.last_name partial_profile.country_code = profile.country_code partial_profile.player_type = profile_pb2.PlayerType.Name(jsf(profile, 'player_type', 1)) partial_profile.male = profile.is_male partial_profile.weight_in_grams = profile.weight_in_grams for f in profile.public_attributes: #0x69520F20=1766985504 - crc32 of "PACE PARTNER - ROUTE" if f.id == 1766985504: 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 return player_partial_profiles[player_id] def get_course(state): return (state.f19 & 0xff0000) >> 16 def road_id(state): return (state.aux3 & 0xff00) >> 8 def is_forward(state): return (state.f19 & 4) != 0 def is_nearby(s1, s2): if s1 is None or s2 is None: return False if s1.watchingRiderId == s2.id or s2.watchingRiderId == s1.id: return True if get_course(s1) == get_course(s2): dist = math.sqrt((s2.x - s1.x)**2 + (s2.z - s1.z)**2 + (s2.y_altitude - s1.y_altitude)**2) if dist <= 100000 or road_id(s1) == road_id(s2): return True return False # We store flask-login's cookie in the "fake" JWT that we give Zwift. # Make it a cookie again to reuse flask-login on API calls. def jwt_to_session_cookie(f): @wraps(f) def wrapper(*args, **kwargs): if not MULTIPLAYER: return f(*args, **kwargs) token = request.headers.get('Authorization') if token and not session.get('_user_id'): token = jwt.decode(token.split()[1], options=({'verify_signature': False, 'verify_aud': False})) request.cookies = request.cookies.copy() # request.cookies is an immutable dict request.cookies['remember_token'] = token['session_cookie'] login_manager._load_user() return f(*args, **kwargs) return wrapper @app.route("/signup/", methods=["GET", "POST"]) def signup(): if request.method == "POST": username = request.form['username'] password = request.form['password'] confirm_password = request.form['confirm_password'] first_name = request.form['first_name'] last_name = request.form['last_name'] if not (username and password and confirm_password and first_name and last_name): flash("All fields are required.") return redirect(url_for('signup')) if not re.match(r"[^@]+@[^@]+\.[^@]+", username): flash("Username is not a valid e-mail address.") return redirect(url_for('signup')) if password != confirm_password: flash("Passwords did not match.") return redirect(url_for('signup')) hashed_pwd = generate_password_hash(password, 'scrypt') new_user = User(username=username, pass_hash=hashed_pwd, first_name=first_name, last_name=last_name) db.session.add(new_user) try: db.session.commit() except sqlalchemy.exc.IntegrityError: flash("Username {u} is not available.".format(u=username)) return redirect(url_for('signup')) flash("User account has been created.") return redirect(url_for("login")) return render_template("signup.html") def check_sha256_hash(pwhash, password): import hmac try: method, salt, hashval = pwhash.split("$", 2) except ValueError: return False return hmac.compare_digest(hmac.new(salt.encode("utf-8"), password.encode("utf-8"), method).hexdigest(), hashval) @app.route("/login/", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form['username'] password = request.form['password'] remember = bool(request.form.get('remember')) if not (username and password): flash("Username and password cannot be empty.") return redirect(url_for('login')) user = User.query.filter_by(username=username).first() if user and user.pass_hash.startswith('sha256'): # sha256 is deprecated in werkzeug 3 if check_sha256_hash(user.pass_hash, password): user.pass_hash = generate_password_hash(password, 'scrypt') db.session.commit() else: flash("Invalid username or password.") return redirect(url_for('login')) if user and check_password_hash(user.pass_hash, password): login_user(user, remember=True) user.remember = remember db.session.commit() 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.warning('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//", methods=["POST", "DELETE"]) @app.route("/api/push/fcm///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, 'scrypt') current_user.pass_hash = hashed_pwd db.session.commit() return '', 200 @app.route("/reset//", methods=["GET", "POST"]) @login_required def reset(username): if request.method == "POST": password = request.form['password'] confirm_password = request.form['confirm_password'] if not (password and confirm_password): flash("All fields are required.") return redirect(url_for('reset', username=current_user.username)) if password != confirm_password: flash("Passwords did not match.") return redirect(url_for('reset', username=current_user.username)) hashed_pwd = generate_password_hash(password, 'scrypt') current_user.pass_hash = hashed_pwd db.session.commit() flash("Password changed.") return redirect(url_for('settings', username=current_user.username)) return render_template("reset.html", username=current_user.username) @app.route("/strava", methods=['GET']) @login_required def strava(): client = Client() url = client.authorization_url(client_id=STRAVA_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(): try: client = Client() code = request.args.get('code') token_response = client.exchange_code_for_token(client_id=STRAVA_CLIENT_ID, client_secret=STRAVA_CLIENT_SECRET, code=code) with open(os.path.join(STORAGE_DIR, str(current_user.player_id), 'strava_token.txt'), 'w') as f: f.write(STRAVA_CLIENT_ID + '\n'); f.write(STRAVA_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.") except Exception as exc: logger.warning('Strava: %s' % repr(exc)) flash("Strava authorization canceled.") return redirect(url_for('settings', username=current_user.username)) def encrypt_credentials(file, cred): try: cipher_suite = AES.new(credentials_key, AES.MODE_CFB) with open(file, 'wb') as f: f.write(cipher_suite.iv) f.write(cipher_suite.encrypt((cred[0] + '\n' + cred[1]).encode('UTF-8'))) flash("Credentials saved.") except Exception as exc: logger.warning('encrypt_credentials: %s' % repr(exc)) flash("Error saving %s" % file) def decrypt_credentials(file): cred = ('', '') if os.path.isfile(file): try: with open(file, 'rb') as f: cipher_suite = AES.new(credentials_key, AES.MODE_CFB, iv=f.read(16)) lines = cipher_suite.decrypt(f.read()).decode('UTF-8').splitlines() cred = (lines[0], lines[1]) except Exception as exc: logger.warning('decrypt_credentials: %s' % repr(exc)) return cred @app.route("/profile//", methods=["GET", "POST"]) @login_required def profile(username): profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id)) file = os.path.join(profile_dir, 'zwift_credentials.bin') cred = decrypt_credentials(file) if request.method == "POST": if request.form['username'] == "" or request.form['password'] == "": flash("Zwift credentials can't be empty.") return render_template("profile.html", username=current_user.username) if not request.form.get("zwift_profile") and not request.form.get("achievements") and not request.form.get("save_zwift"): flash("Select at least one option.") return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1]) username = request.form['username'] password = request.form['password'] session = requests.session() try: access_token, refresh_token = online_sync.login(session, username, password) try: if request.form.get("zwift_profile"): profile = online_sync.query(session, access_token, "api/profiles/me") with open('%s/profile.bin' % profile_dir, 'wb') as f: f.write(profile) if request.form.get("achievements"): achievements = online_sync.query(session, access_token, "achievement/loadPlayerAchievements") with open('%s/achievements.bin' % profile_dir, 'wb') as f: f.write(achievements) online_sync.logout(session, refresh_token) if request.form.get("save_zwift"): encrypt_credentials(file, (username, password)) except Exception as exc: logger.warning('Zwift profile: %s' % repr(exc)) flash("Error downloading profile.") return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1]) except Exception as exc: logger.warning('online_sync.login: %s' % repr(exc)) flash("Invalid username or password.") return render_template("profile.html", username=current_user.username) return redirect(url_for('settings', username=current_user.username)) return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1]) @app.route("/garmin//", methods=["GET", "POST"]) @login_required def garmin(username): file = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id) if request.method == "POST": if request.form['username'] == "" or request.form['password'] == "": flash("Garmin credentials can't be empty.") return render_template("garmin.html", username=current_user.username) encrypt_credentials(file, (request.form['username'], request.form['password'])) rmtree('%s/%s/garth' % (STORAGE_DIR, current_user.player_id), ignore_errors=True) return redirect(url_for('settings', username=current_user.username)) cred = decrypt_credentials(file) return render_template("garmin.html", username=current_user.username, uname=cred[0], passw=cred[1]) @app.route("/intervals//", methods=["GET", "POST"]) @login_required def intervals(username): file = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, current_user.player_id) if request.method == "POST": if request.form['athlete_id'] == "" or request.form['api_key'] == "": flash("Intervals.icu credentials can't be empty.") return render_template("intervals.html", username=current_user.username) encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key'])) return redirect(url_for('settings', username=current_user.username)) cred = decrypt_credentials(file) return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1]) @app.route("/user//") @login_required def user_home(username): return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), climbs=CLIMBS, online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes) def enqueue_player_update(player_id, wa_bytes): if not player_id in player_update_queue: player_update_queue[player_id] = list() player_update_queue[player_id].append(wa_bytes) def send_message(message, sender='Server', recipients=online.keys()): player_update = udp_node_msgs_pb2.WorldAttribute() player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_SPA player_update.world_time_born = world_time() player_update.world_time_expire = world_time() + 60000 player_update.wa_f12 = 1 player_update.timestamp = int(time.time()*1000000) chat_message = tcp_node_msgs_pb2.SocialPlayerAction() chat_message.player_id = 0 chat_message.to_player_id = 0 chat_message.spa_type = tcp_node_msgs_pb2.SocialPlayerActionType.SOCIAL_TEXT_MESSAGE chat_message.firstName = sender chat_message.lastName = '' chat_message.message = message chat_message.countryCode = 0 player_update.payload = chat_message.SerializeToString() player_update_s = player_update.SerializeToString() for receiving_player_id in recipients: enqueue_player_update(receiving_player_id, player_update_s) def send_restarting_message(): global restarting global restarting_in_minutes while restarting: send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes) time.sleep(60) restarting_in_minutes -= 1 if restarting and restarting_in_minutes == 0: message = 'See you later! Look for the back online message.' send_message(message) discord.send_message(message) time.sleep(6) os.kill(os.getpid(), signal.SIGINT) @app.route("/restart") @login_required def restart_server(): global restarting global restarting_in_minutes if bool(current_user.is_admin): restarting = True restarting_in_minutes = 10 send_restarting_message_thread = threading.Thread(target=send_restarting_message) send_restarting_message_thread.start() discord.send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes) return redirect(url_for('user_home', username=current_user.username)) @app.route("/cancelrestart") @login_required def cancel_restart_server(): global restarting global restarting_in_minutes if bool(current_user.is_admin): restarting = False restarting_in_minutes = 0 message = 'Restart of the server has been cancelled. Ride on!' send_message(message) discord.send_message(message) return redirect(url_for('user_home', username=current_user.username)) @app.route("/reloadbots") @login_required def reload_bots(): global reload_pacer_bots if bool(current_user.is_admin): reload_pacer_bots = True return redirect(url_for('user_home', username=current_user.username)) @app.route("/settings//", methods=["GET", "POST"]) @login_required def settings(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', 'achievements.bin']: file_path = os.path.join(profile_dir, uploaded_file.filename) uploaded_file.save(file_path) else: flash("Invalid file 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)) achievements = None achievements_file = os.path.join(profile_dir, 'achievements.bin') if os.path.isfile(achievements_file): stat = os.stat(achievements_file) achievements = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime)) token = os.path.isfile(os.path.join(profile_dir, 'strava_token.txt')) return render_template("settings.html", username=current_user.username, profile=profile, achievements=achievements, token=token) @app.route("/download/", methods=["GET"]) @login_required def download(filename): file = os.path.join(STORAGE_DIR, str(current_user.player_id), filename) if os.path.isfile(file): return send_file(file) @app.route("/download//avatarLarge.jpg", methods=["GET"]) def download_avatarLarge(player_id): profile_file = os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg') if os.path.isfile(profile_file): return send_file(profile_file, mimetype='image/jpeg') else: return '', 404 @app.route("/delete/", methods=["GET"]) @login_required def delete(filename): player_id = current_user.player_id credentials = ['garmin_credentials.bin', 'zwift_credentials.bin', 'intervals_credentials.bin'] if filename not in ['profile.bin', 'achievements.bin', 'strava_token.txt'] and filename not in credentials: 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) if filename in credentials: flash("Credentials removed.") return redirect(url_for('settings', username=current_user.username)) @app.route("/power_curves/", methods=["GET", "POST"]) @login_required def power_curves(username): if request.method == "POST": player_id = current_user.player_id PowerCurve.query.filter_by(player_id=player_id).delete() db.session.commit() if request.form.get('create'): fit_dir = os.path.join(STORAGE_DIR, str(player_id), 'fit') if os.path.isdir(fit_dir): for fit_file in os.listdir(fit_dir): create_power_curve(player_id, os.path.join(fit_dir, fit_file)) flash("Power curves created.") else: flash("Power curves deleted.") return redirect(url_for('settings', username=current_user.username)) return render_template("power_curves.html", username=current_user.username) @app.route("/logout/") @login_required def logout(username): session.clear() logout_user() flash("Successfully logged out.") return redirect(url_for('login')) def insert_protobuf_into_db(table_name, msg, exclude_fields=[]): msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True) for key in exclude_fields: if key in msg_dict: del msg_dict[key] if 'id' in msg_dict: del msg_dict['id'] row = table_name(**msg_dict) db.session.add(row) db.session.commit() return row.id def update_protobuf_in_db(table_name, msg, id, exclude_fields=[]): msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True) for key in exclude_fields: if key in msg_dict: del msg_dict[key] table_name.query.filter_by(id=id).update(msg_dict) db.session.commit() def row_to_protobuf(row, msg, exclude_fields=[]): for key in row.keys(): if key in exclude_fields: continue if row[key] is None: continue setattr(msg, key, row[key]) return msg def world_time(): return int((time.time()-1414016075)*1000) @app.route('/api/clubs/club/can-create', methods=['GET']) def api_clubs_club_cancreate(): return jsonify({"reason": "DISABLED", "result": False}) @app.route('/api/event-feed', methods=['GET']) #from=1646723199600&limit=25&sport=CYCLING def api_eventfeed(): limit = int(request.args.get('limit')) sport = request.args.get('sport') events = get_events(limit, sport) json_events = convert_events_to_json(events) json_data = [] for e in json_events: json_data.append({"event": e}) return jsonify({"data":json_data,"cursor":None}) @app.route('/api/recommendations/recommendation', methods=['GET']) def api_recommendations_recommendation(): return jsonify([{"type": "EVENT"}]) @app.route('/api/campaign/profile/campaigns', methods=['GET']) @app.route('/api/announcements/active', methods=['GET']) @app.route('/api/recommendation/profile', methods=['GET']) @app.route('/api/subscription/plan', methods=['GET']) @app.route('/api/quest/quests/all-quests', methods=['GET']) @app.route('/api/quest/quests/my-quests', methods=['GET']) def api_empty_arrays(): return jsonify([]) def activity_moving_time(activity): try: return int((datetime.datetime.strptime(activity.end_date, '%Y-%m-%dT%H:%M:%SZ') - datetime.datetime.strptime(activity.start_date, '%Y-%m-%dT%H:%M:%SZ')).total_seconds() * 1000) except: return 0 def activity_row_to_json(activity, details=False): profile = get_partial_profile(activity.player_id) data = {"id":activity.id,"profile":{"id":str(activity.player_id),"firstName":profile.first_name,"lastName":profile.last_name, "imageSrc":profile.imageSrc,"approvalRequired":None},"worldId":activity.course_id,"name":activity.name,"sport":str_sport(activity.sport), "startDate":activity.start_date,"endDate":activity.end_date,"distanceInMeters":activity.distanceInMeters, "totalElevation":activity.total_elevation,"calories":activity.calories,"primaryImageUrl":"","feedImageThumbnailUrl":"", "lastSaveDate":activity.date,"movingTimeInMs":activity_moving_time(activity),"avgSpeedInMetersPerSecond":activity.avg_speed, "activityRideOnCount":0,"activityCommentCount":0,"privacy":"PUBLIC","eventId":None,"rideOnGiven":False,"id_str":str(activity.id)} if details: extra_data = {"avgWatts":activity.avg_watts,"maxWatts":activity.max_watts,"avgHeartRate":activity.avg_heart_rate, "maxHeartRate":activity.max_heart_rate,"avgCadenceInRotationsPerMinute":activity.avg_cadence, "maxCadenceInRotationsPerMinute":activity.max_cadence,"maxSpeedInMetersPerSecond":activity.max_speed} data.update(extra_data) return data def select_activities_json(player_id, limit, start_after=None): filters = [Activity.distanceInMeters > 100] if player_id: filters.append(Activity.player_id == player_id) if start_after: filters.append(Activity.id < int(start_after)) rows = Activity.query.filter(*filters).order_by(Activity.date.desc()).limit(limit) ret = [] for row in rows: if row.end_date: ret.append(activity_row_to_json(row)) return ret @app.route('/api/activity-feed/feed/', methods=['GET']) @jwt_to_session_cookie @login_required def api_activity_feed(): limit = int(request.args.get('limit')) feed_type = request.args.get('feedType') start_after = request.args.get('start_after_activity_id') if feed_type == 'JUST_ME' or feed_type == 'PREVIEW': #what is the difference here? profile_id = current_user.player_id elif feed_type == 'OTHER_PROFILE': profile_id = int(request.args.get('profile_id')) else: # todo: FAVORITES, FOLLOWEES (showing all for now) profile_id = None ret = select_activities_json(profile_id, limit, start_after) return jsonify(ret) def create_activity_file(fit_file, small_file, full_file=None): data = {"powerInWatts": [], "cadencePerMin": [], "heartRate": [], "distanceInCm": [], "speedInCmPerSec": [], "timeInSec": [], "altitudeInCm": [], "latlng": []} start_time = 0 with fitdecode.FitReader(fit_file) as fit: for frame in fit: if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record': power = cadence = heart_rate = distance = speed = time = altitude = position_lat = position_long = None for f in frame.fields: if f.name == "power" and f.value is not None: power = int(f.value) elif f.name == "cadence" and f.value is not None: cadence = int(f.value) elif f.name == "heart_rate" and f.value is not None: heart_rate = int(f.value) elif f.name == "distance" and f.value is not None: distance = int(f.value * 100) elif f.name == "speed" and f.value is not None: speed = int(f.value * 100) elif f.name == "timestamp" and f.value is not None: timestamp = int(f.value.timestamp()) if start_time == 0: start_time = timestamp time = timestamp - start_time elif f.name == "altitude" and f.value is not None: altitude = int(f.value * 100) elif f.name == "position_lat" and f.value is not None: position_lat = round(f.value / 11930465, 6) elif f.name == "position_long" and f.value is not None: position_long = round(f.value / 11930465, 6) if None not in {power, cadence, heart_rate, distance, speed, time, altitude, position_lat, position_long}: data["powerInWatts"].append(power) data["cadencePerMin"].append(cadence) data["heartRate"].append(heart_rate) data["distanceInCm"].append(distance) data["speedInCmPerSec"].append(speed) data["timeInSec"].append(time) data["altitudeInCm"].append(altitude) data["latlng"].append([position_lat, position_long]) if data["powerInWatts"]: if full_file: with open(full_file, 'w') as f: json.dump(data, f) step = len(data["powerInWatts"]) // 1000 if step > 1: for d in data: data[d] = data[d][::step] with open(small_file, 'w') as f: json.dump(data, f) @app.route('/api/activities/', methods=['GET']) @jwt_to_session_cookie @login_required def api_activities(activity_id): row = Activity.query.filter_by(id=activity_id).first() if row: activity = activity_row_to_json(row, True) activities_dir = '%s/activities' % STORAGE_DIR try: if not os.path.isdir(activities_dir): os.makedirs(activities_dir) except IOError as e: logger.error("failed to create activities dir (%s): %s", activities_dir, str(e)) return '', 400 fit_file = '%s/%s/fit/%s - %s' % (STORAGE_DIR, row.player_id, row.id, row.fit_filename) # fullDataUrl is never fetched, creating only downsampled file file = ActivityFile.query.filter_by(activity_id=row.id, full=0).first() if not file and os.path.isfile(fit_file): file = ActivityFile(activity_id=row.id, full=0) db.session.add(file) db.session.commit() if file: activity_file = '%s/%s' % (activities_dir, file.id) if not os.path.isfile(activity_file) and os.path.isfile(fit_file): try: create_activity_file(fit_file, activity_file) except Exception as exc: logger.warning('create_activity_file: %s' % repr(exc)) if os.path.isfile(activity_file): url = 'https://us-or-rly101.zwift.com/api/activities/%s/file/%s' % (row.id, file.id) data = {"fitnessData": {"status": "AVAILABLE", "fullDataUrl": url, "smallDataUrl": url}} activity.update(data) return jsonify(activity) return '', 404 @app.route('/api/activities//file/') def api_activities_file(activity_id, file): return send_from_directory('%s/activities' % STORAGE_DIR, file) @app.route('/api/auth', methods=['GET']) def api_auth(): return {"realm": "zwift","launcher": "https://launcher.zwift.com/launcher","url": "https://secure.zwift.com/auth/"} @app.route('/api/server', methods=['GET']) def api_server(): return {"build":"zwift_1.267.0","version":"1.267.0"} @app.route('/api/servers', methods=['GET']) def api_servers(): return {"baseUrl":"https://us-or-rly101.zwift.com/relay"} @app.route('/api/clubs/club/list/my-clubs', methods=['GET']) @app.route('/api/clubs/club/reset-my-active-club.proto', methods=['POST']) @app.route('/api/clubs/club/featured', methods=['GET']) @app.route('/api/clubs/club', methods=['GET']) def api_clubs(): return jsonify({"total": 0, "results": []}) @app.route('/api/clubs/club/my-clubs-summary', methods=['GET']) def api_clubs_club_my_clubs_summary(): return jsonify({"invitedCount": 0, "requestedCount": 0, "results": []}) @app.route('/api/clubs/club/list/my-clubs.proto', methods=['GET']) @app.route('/api/campaign/proto/campaigns', methods=['GET']) @app.route('/api/campaign/public/proto/campaigns/active', methods=['GET']) @app.route('/api/player-playbacks/player/settings', methods=['GET', 'POST']) # TODO: private = \x08\x01 (1: 1) @app.route('/api/scoring/current', methods=['GET']) @app.route('/api/game-asset-patching-service/manifest', methods=['GET']) @app.route('/api/race-results', methods=['POST']) def api_proto_empty(): return '', 200 @app.route('/api/game_info/version', methods=['GET']) def api_gameinfo_version(): game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt") with open(game_info_file, mode="r", encoding="utf-8-sig") as f: data = json.load(f) return {"version": data['gameInfoHash']} @app.route('/api/game_info', methods=['GET']) def api_gameinfo(): game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt") with open(game_info_file, mode="r", encoding="utf-8-sig") as f: r = make_response(f.read()) r.mimetype = 'application/json' return r @app.route('/api/users/login', methods=['POST']) @jwt_to_session_cookie @login_required def api_users_login(): req = login_pb2.LoginRequest() req.ParseFromString(request.stream.read()) player_id = current_user.player_id global_relay[player_id] = Relay(req.key) response = login_pb2.LoginResponse() response.session_state = 'abc' response.info.relay_url = "https://us-or-rly101.zwift.com/relay" response.info.apis.todaysplan_url = "https://whats.todaysplan.com.au" response.info.apis.trainingpeaks_url = "https://api.trainingpeaks.com" response.info.time = int(time.time()) udp_node = response.info.nodes.nodes.add() udp_node.ip = server_ip # TCP telemetry server udp_node.port = 3023 response.relay_session_id = player_id response.expiration = 70 profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id)) config_file = os.path.join(profile_dir, 'economy_config.txt') if not os.path.isfile(config_file): with open(os.path.join(SCRIPT_DIR, 'data', 'economy_config.txt')) as f: economy_config = json.load(f) profile_file = os.path.join(profile_dir, 'profile.bin') if os.path.isfile(profile_file): profile = profile_pb2.PlayerProfile() with open(profile_file, 'rb') as f: profile.ParseFromString(f.read()) current_level = profile.achievement_level // 100 levels = [x for x in economy_config['cycling_levels'] if x['level'] >= current_level] if len(levels) > 1 and profile.total_xp > levels[1]['xp']: offset = profile.total_xp - levels[0]['xp'] transition_end = [x for x in levels if x['xp'] <= profile.total_xp][-1]['level'] for level in economy_config['cycling_levels']: if level['level'] >= current_level: level['xp'] += offset if transition_end > current_level: economy_config['transition_start'] = current_level economy_config['transition_end'] = transition_end with open(config_file, 'w') as f: json.dump(economy_config, f, indent=2) with open(config_file) as f: Parse(f.read(), response.economy_config) return response.SerializeToString(), 200 @app.route('/relay/session/refresh', methods=['POST']) @jwt_to_session_cookie @login_required def relay_session_refresh(): refresh = login_pb2.RelaySessionRefreshResponse() refresh.relay_session_id = current_user.player_id refresh.expiration = 70 return refresh.SerializeToString(), 200 def logout_player(player_id): #Remove player from online when leaving game/world if player_id in online: online.pop(player_id) discord.change_presence(len(online)) if player_id in global_ghosts: del global_ghosts[player_id].rec.states[:] global_ghosts[player_id].play.clear() global_ghosts.pop(player_id) if player_id in 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(): return '', 204 @app.route('/api/analytics/event', methods=['POST']) def api_analytics_event(): #print(json.dumps(request.json, indent=4)) return '', 200 @app.route('/api/per-session-info', methods=['GET']) def api_per_session_info(): info = per_session_info_pb2.PerSessionInfo() info.relay_url = "https://us-or-rly101.zwift.com/relay" return info.SerializeToString(), 200 def get_events(limit=None, sport=None): with open(os.path.join(SCRIPT_DIR, 'data', 'events.txt')) as f: events_list = json.load(f) events = events_pb2.Events() eventStart = int(time.time()) * 1000 + 2 * 60000 eventStartWT = world_time() + 2 * 60000 event_id = 0 for item in events_list: event_id += 10 if sport != None and item['sport'] != profile_pb2.Sport.Value(sport): continue event = events.events.add() event.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID event.id = event_id event.name = item['name'] event.route_id = item['route'] #otherwise new home screen hangs trying to find route in all (even non-existent) courses event.course_id = item['course'] event.sport = item['sport'] event.lateJoinInMinutes = 30 event.eventStart = eventStart event.visible = True event.overrideMapPreferences = False event.invisibleToNonParticipants = False event.description = "Auto-generated event" event.distanceInMeters = item['distance'] event.laps = 0 event.durationInSeconds = 0 #event.rules_id = #event.jerseyHash = event.eventType = events_pb2.EventType.RACE #event.e_f27 = 27; //<=4, ENUM? #event.tags = 31; // semi-colon delimited tags event.e_wtrl = False # WTRL (World Tactical Racing Leagues) cats = ('A', 'B', 'C', 'D', 'E', 'F') paceValues = ((4,15), (3,4), (2,3), (1,2), (0.1,1)) for cat in range(1,5): event_cat = event.category.add() event_cat.id = event_id + cat #event_cat.registrationStart = eventStart - 30 * 60000 #event_cat.registrationStartWT = eventStartWT - 30 * 60000 event_cat.registrationEnd = eventStart event_cat.registrationEndWT = eventStartWT #event_cat.lineUpStart = eventStart - 5 * 60000 #event_cat.lineUpStartWT = eventStartWT - 5 * 60000 #event_cat.lineUpEnd = eventStart #event_cat.lineUpEndWT = eventStartWT event_cat.eventSubgroupStart = eventStart - 2 * 60000 # fixes HUD timer event_cat.eventSubgroupStartWT = eventStartWT - 2 * 60000 event_cat.route_id = item['route'] event_cat.startLocation = cat event_cat.label = cat event_cat.lateJoinInMinutes = 30 event_cat.name = "Cat. %s" % cats[cat - 1] event_cat.description = "#zwiftoffline" event_cat.course_id = event.course_id event_cat.paceType = 1 #1 almost everywhere, 2 sometimes event_cat.fromPaceValue = paceValues[cat - 1][0] event_cat.toPaceValue = paceValues[cat - 1][1] #event_cat.scode = 7; // ex: "PT3600S" #event_cat.rules_id = 8; // 320 and others event_cat.distanceInMeters = item['distance'] event_cat.laps = 0 event_cat.durationInSeconds = 0 #event_cat.jerseyHash = 36; // 493134166, tag672 #event_cat.tags = 45; // tag746, semi-colon delimited tags eg: "fenced;3r;created_ryan;communityevent;no_kick_mode;timestamp=1603911177622" if limit != None and len(events.events) >= limit: break return events @app.route('/api/events/', methods=['GET']) def api_events_id(event_id): events = get_events() for e in events.events: if e.id == event_id: return jsonify(convert_event_to_json(e)) return '', 200 @app.route('/api/events/search', methods=['POST']) def api_events_search(): limit = int(request.args.get('limit')) events = get_events(limit) if request.headers['Accept'] == 'application/json': return jsonify(convert_events_to_json(events)) else: return events.SerializeToString(), 200 def create_event_wat(rel_id, wa_type, pe, dest_ids): player_update = udp_node_msgs_pb2.WorldAttribute() player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID player_update.wa_type = wa_type player_update.world_time_born = world_time() player_update.world_time_expire = world_time() + 60000 player_update.wa_f12 = 1 player_update.timestamp = int(time.time()*1000000) player_update.rel_id = current_user.player_id pe.rel_id = rel_id pe.player_id = current_user.player_id #optional uint64 pje_f3/ple_f3 = 3; player_update.payload = pe.SerializeToString() player_update_s = player_update.SerializeToString() if not current_user.player_id in dest_ids: dest_ids = list(dest_ids) dest_ids.append(current_user.player_id) for receiving_player_id in dest_ids: enqueue_player_update(receiving_player_id, player_update_s) @app.route('/api/events/subgroups/signup/', methods=['POST']) @app.route('/api/events/signup/', methods=['DELETE']) @jwt_to_session_cookie @login_required def api_events_subgroups_signup_id(rel_id): if request.method == 'POST': wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_JOIN_E pe = events_pb2.PlayerJoinedEvent() ret = True else: wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_LEFT_E pe = events_pb2.PlayerLeftEvent() ret = False #empty request.data create_event_wat(rel_id, wa_type, pe, online.keys()) return jsonify({"signedUp":ret}) @app.route('/api/events/subgroups/register/', methods=['POST']) def api_events_subgroups_register_id(ev_sg_id): return '{"registered":true}', 200 @app.route('/api/events/subgroups/entrants/', methods=['GET']) def api_events_subgroups_entrants_id(ev_sg_id): return '', 200 @app.route('/api/events/subgroups/invited_ride_sweepers/', methods=['GET']) def api_events_subgroups_invited_ride_sweepers_id(ev_sg_id): return '[]', 200 @app.route('/api/events/subgroups/invited_ride_leaders/', methods=['GET']) def api_events_subgroups_invited_ride_leaders_id(ev_sg_id): return '[]', 200 @app.route('/relay/race/event_starting_line/', methods=['POST']) def relay_race_event_starting_line_id(event_id): return '', 204 @app.route('/api/zfiles', methods=['POST']) @jwt_to_session_cookie @login_required def api_zfiles(): if request.headers['Source'] == 'zwift-companion': zfile = json.loads(request.stream.read()) zfile_folder = zfile['folder'] zfile_filename = zfile['name'] zfile_file = base64.b64decode(zfile['content']) else: zfile = zfiles_pb2.ZFileProto() zfile.ParseFromString(request.stream.read()) zfile_folder = zfile.folder zfile_filename = zfile.filename zfile_file = zfile.file zfiles_dir = os.path.join(STORAGE_DIR, str(current_user.player_id), zfile_folder) try: if not os.path.isdir(zfiles_dir): os.makedirs(zfiles_dir) except IOError as e: logger.error("failed to create zfiles dir (%s): %s", zfiles_dir, str(e)) return '', 400 try: zfile_filename = zfile_filename.decode('utf-8', 'ignore') except AttributeError: pass with open(os.path.join(zfiles_dir, quote(zfile_filename, safe=' ')), 'wb') as fd: fd.write(zfile_file) row = Zfile.query.filter_by(folder=zfile_folder, filename=zfile_filename, player_id=current_user.player_id).first() if not row: zfile_timestamp = int(time.time()) new_zfile = Zfile(folder=zfile_folder, filename=zfile_filename, timestamp=zfile_timestamp, player_id=current_user.player_id) db.session.add(new_zfile) db.session.commit() zfile_id = new_zfile.id else: zfile_id = row.id zfile_timestamp = row.timestamp if request.headers['Accept'] == 'application/json': return jsonify({"id":zfile_id,"folder":zfile_folder,"name":zfile_filename,"content":None,"lastModified":str_timestamp(zfile_timestamp*1000)}) else: response = zfiles_pb2.ZFileProto() response.id = zfile_id response.folder = zfile_folder response.filename = zfile_filename response.timestamp = zfile_timestamp return response.SerializeToString(), 200 @app.route('/api/zfiles/list', methods=['GET']) @jwt_to_session_cookie @login_required def api_zfiles_list(): folder = request.args.get('folder') zfiles = zfiles_pb2.ZFilesProto() rows = Zfile.query.filter_by(folder=folder, player_id=current_user.player_id) for row in rows: zfiles.zfiles.add(id=row.id, folder=row.folder, filename=row.filename, timestamp=row.timestamp) return zfiles.SerializeToString(), 200 @app.route('/api/zfiles//download', methods=['GET']) @jwt_to_session_cookie @login_required def api_zfiles_download(file_id): row = Zfile.query.filter_by(id=file_id).first() zfile = os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' ')) if os.path.isfile(zfile): return send_file(zfile, as_attachment=True, download_name=row.filename) else: return '', 404 @app.route('/api/zfiles/', methods=['DELETE']) @jwt_to_session_cookie @login_required def api_zfiles_delete(file_id): row = Zfile.query.filter_by(id=file_id).first() try: os.remove(os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' '))) except Exception as exc: logger.warning('api_zfiles_delete: %s' % repr(exc)) db.session.delete(row) db.session.commit() return '', 200 # Custom static data @app.route('/style/') def custom_style(filename): return send_from_directory('%s/cdn/style' % SCRIPT_DIR, filename) @app.route('/static/web/launcher/') def static_web_launcher(filename): return send_from_directory('%s/cdn/static/web/launcher' % SCRIPT_DIR, filename) @app.route('/api/telemetry/config', methods=['GET']) def api_telemetry_config(): return jsonify({"analyticsEvents": True, "batchInterval": 120, "innermostCullingRadius": 1500, "isEnabled": True, "key": "aXBSdlpza3p1aVlNOENrMTBQSzZEZ004Z2pwRm8zZUE6", "remoteLogLevel": 3, "sampleInterval": 60, "url": "https://us-or-rly101.zwift.com/v1/track", # used if no urlBatch (https://api.segment.io/v1/track) "urlBatch": "https://us-or-rly101.zwift.com/hvc-ingestion-service/batch"}) @app.route('/v1/track', methods=['POST']) @app.route('/hvc-ingestion-service/batch', methods=['POST']) def hvc_ingestion_service_batch(): #print(json.dumps(request.json, indent=4)) return jsonify({"success": True}) def age(dob): today = datetime.date.today() years = today.year - dob.year if today.month < dob.month or (today.month == dob.month and today.day < dob.day): years -= 1 return years def jsf(obj, field, deflt = None): if obj.HasField(field): return getattr(obj, field) return deflt def jsb0(obj, field): return jsf(obj, field, False) def jsb1(obj, field): return jsf(obj, field, True) def jsv0(obj, field): return jsf(obj, field, 0) def jses(obj, field): return str(jsf(obj, field)) def copyAttributes(jprofile, jprofileFull, src): dict = jprofileFull.get(src) if dict is None: return dest = {} for di in dict: for v in ['numberValue', 'floatValue', 'stringValue']: if v in di: dest[di['id']] = di[v] jprofile[src] = dest def powerSourceModelToStr(val): if val == 1: return "Power Meter" else: return "zPower" def privacy(profile): privacy_bits = jsf(profile, 'privacy_bits', 0) return {"approvalRequired": bool(privacy_bits & 1), "displayWeight": bool(privacy_bits & 4), "minor": bool(privacy_bits & 2), "privateMessaging": bool(privacy_bits & 8), "defaultFitnessDataPrivacy": bool(privacy_bits & 16), "suppressFollowerNotification": bool(privacy_bits & 32), "displayAge": not bool(privacy_bits & 64), "defaultActivityPrivacy": profile_pb2.ActivityPrivacyType.Name(jsv0(profile, 'default_activity_privacy'))} def bikeFrameToStr(val): if val in GD['bikeframes']: return GD['bikeframes'][val] return "---" def do_api_profiles(profile_id, is_json): profile = profile_pb2.PlayerProfile() profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, profile_id) 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()) profile.id = profile_id if not profile.email: profile.email = 'user@email.com' if profile.entitlements: del profile.entitlements[:] if not profile.mix_panel_distinct_id: profile.mix_panel_distinct_id = str(uuid.uuid4()) if is_json: #todo: publicId, bodyType, totalRunCalories != total_watt_hours, totalRunTimeInMinutes != time_ridden_in_minutes etc if profile.dob != "": profile.age = age(datetime.datetime.strptime(profile.dob, "%m/%d/%Y")) jprofileFull = MessageToDict(profile) jprofile = {"id": profile.id, "firstName": jsf(profile, 'first_name'), "lastName": jsf(profile, 'last_name'), "preferredLanguage": jsf(profile, 'preferred_language'), "bodyType":jsv0(profile, 'body_type'), "male": jsb1(profile, 'is_male'), "imageSrc": imageSrc(profile.id), "imageSrcLarge": imageSrc(profile.id), "playerType": profile_pb2.PlayerType.Name(jsf(profile, 'player_type', 1)), "playerTypeId": jsf(profile, 'player_type', 1), "playerSubTypeId": None, "emailAddress": jsf(profile, 'email'), "countryCode": jsf(profile, 'country_code'), "dob": jsf(profile, 'dob'), "countryAlpha3": "rus", "useMetric": jsb1(profile, 'use_metric'), "privacy": privacy(profile), "age": jsv0(profile, 'age'), "ftp": jsf(profile, 'ftp'), "b": False, "weight": jsf(profile, 'weight_in_grams'), "connectedToStrava": jsb0(profile, 'connected_to_strava'), "connectedToTrainingPeaks": jsb0(profile, 'connected_to_training_peaks'), "connectedToTodaysPlan": jsb0(profile, 'connected_to_todays_plan'), "connectedToUnderArmour": jsb0(profile, 'connected_to_under_armour'), "connectedToFitbit": jsb0(profile, 'connected_to_fitbit'), "connectedToGarmin": jsb0(profile, 'connected_to_garmin'), "height": jsf(profile, 'height_in_millimeters'), "location": "", "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'], strict_slashes=False) @jwt_to_session_cookie @login_required def api_profiles_me(): if request.headers['Source'] == "zwift-companion": return do_api_profiles(current_user.player_id, True) else: return do_api_profiles(current_user.player_id, False) @app.route('/api/profiles/', methods=['GET']) @jwt_to_session_cookie @login_required def api_profiles_json(profile_id): return do_api_profiles(profile_id, True) @app.route('/api/partners/garmin/auth', methods=['GET']) @app.route('/api/partners/trainingpeaks/auth', methods=['GET']) @app.route('/api/partners/strava/auth', methods=['GET']) @app.route('/api/partners/withings/auth', methods=['GET']) @app.route('/api/partners/todaysplan/auth', methods=['GET']) @app.route('/api/partners/runtastic/auth', methods=['GET']) @app.route('/api/partners/underarmour/auth', methods=['GET']) @app.route('/api/partners/fitbit/auth', methods=['GET']) def api_profiles_partners(): return {"status":"notConnected","clientId":"zwift","sandbox":False} @app.route('/api/profiles//privacy', methods=['POST']) @jwt_to_session_cookie @login_required def api_profiles_id_privacy(player_id): privacy_file = '%s/%s/privacy.json' % (STORAGE_DIR, player_id) jp = request.get_json() with open(privacy_file, 'w', encoding='utf-8') as fprivacy: fprivacy.write(json.dumps(jp, ensure_ascii=False)) #{"displayAge": false, "defaultActivityPrivacy": "PUBLIC", "approvalRequired": false, "privateMessaging": false, "defaultFitnessDataPrivacy": false} profile_dir = '%s/%s' % (STORAGE_DIR, player_id) profile = profile_pb2.PlayerProfile() profile_file = '%s/profile.bin' % profile_dir with open(profile_file, 'rb') as fd: profile.ParseFromString(fd.read()) profile.privacy_bits = 0 if jp["approvalRequired"]: profile.privacy_bits += 1 if "displayWeight" in jp and jp["displayWeight"]: profile.privacy_bits += 4 if "minor" in jp and jp["minor"]: profile.privacy_bits += 2 if jp["privateMessaging"]: profile.privacy_bits += 8 if jp["defaultFitnessDataPrivacy"]: profile.privacy_bits += 16 if "suppressFollowerNotification" in jp and jp["suppressFollowerNotification"]: profile.privacy_bits += 32 if not jp["displayAge"]: profile.privacy_bits += 64 defaultActivityPrivacy = jp["defaultActivityPrivacy"] profile.default_activity_privacy = 0 #PUBLIC if defaultActivityPrivacy == "PRIVATE": profile.default_activity_privacy = 1 if defaultActivityPrivacy == "FRIENDS": profile.default_activity_privacy = 2 with open(profile_file, 'wb') as fd: fd.write(profile.SerializeToString()) return '', 200 @app.route('/api/profiles//followers', methods=['GET']) #?start=0&limit=200&include-follow-requests=false @app.route('/api/profiles//followees', methods=['GET']) @app.route('/api/profiles//followees-in-common/', methods=['GET']) @jwt_to_session_cookie @login_required def api_profiles_followers(m_player_id, t_player_id=0): rows = db.session.execute(sqlalchemy.text("SELECT player_id, first_name, last_name FROM user")) json_data_list = [] for row in rows: player_id = row[0] profile = get_partial_profile(player_id) #all users are following favourites of this user (temp decision for small crouds) json_data_list.append({"id":0,"followerId":player_id,"followeeId":m_player_id,"status":"IS_FOLLOWING","isFolloweeFavoriteOfFollower":True, "followerProfile":{"id":player_id,"firstName":row[1],"lastName":row[2],"imageSrc":imageSrc(player_id),"imageSrcLarge":imageSrc(player_id),"countryCode":profile.country_code}, "followeeProfile":None}) return jsonify(json_data_list) @app.route('/api/search/profiles/restricted', methods=['POST']) @app.route('/api/search/profiles', methods=['POST']) @jwt_to_session_cookie @login_required def api_search_profiles(): query = request.json['query'] start = request.args.get('start') limit = request.args.get('limit') stmt = sqlalchemy.text("SELECT player_id, first_name, last_name FROM user WHERE first_name LIKE :n OR last_name LIKE :n LIMIT :l OFFSET :o") rows = db.session.execute(stmt, {"n": "%"+query+"%", "l": limit, "o": start}) json_data_list = [] for row in rows: player_id = row[0] profile = get_partial_profile(player_id) json_data_list.append({"id": player_id, "firstName": row[1], "lastName": row[2], "imageSrc": imageSrc(player_id), "imageSrcLarge": imageSrc(player_id), "countryCode": profile.country_code}) return jsonify(json_data_list) @app.route('/api/profiles//membership-status', methods=['GET']) def api_profiles_membership_status(player_id): return jsonify({"status":"active"}) # {"title":"25km","description":"renews.1677628800000","status":"active","upcoming":null,"subscription":null,"promotions":[],"hasStackedPromos":false,"startedPortability":false,"grandfathered":false,"grandfatheringGroup":null,"freeTrialKmLeft":18} @app.route('/api/profiles//statistics', methods=['GET']) def api_profiles_id_statistics(player_id): from_dt = request.args.get('startDateTime') stmt = sqlalchemy.text("SELECT SUM(CAST((julianday(date)-julianday(start_date))*24*60 AS integer)), SUM(distanceInMeters), SUM(calories), SUM(total_elevation) FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :d)") row = db.session.execute(stmt, {"p": player_id, "d": from_dt}).first() json_data = {"timeRiddenInMinutes": row[0], "distanceRiddenInMeters": row[1], "caloriesBurned": row[2], "heightClimbedInMeters": row[3]} return jsonify(json_data) @app.route('/relay/profiles/me/phone', methods=['PUT']) @jwt_to_session_cookie @login_required def api_profiles_me_phone(): global zc_connect_queue if not request.stream: return '', 400 phoneAddress = request.json['phoneAddress'] if 'port' in request.json: phonePort = int(request.json['port']) phoneSecretKey = 'None' if 'securePort' in request.json: phonePort = int(request.json['securePort']) phoneSecretKey = base64.b64decode(request.json['secret']) zc_connect_queue[current_user.player_id] = (phoneAddress, phonePort, phoneSecretKey) #todo UDP scenario #logger.info("ZCompanion %d reg: %s:%d (key: %s)" % (current_user.player_id, phoneAddress, phonePort, phoneSecretKey.hex())) return '', 204 @app.route('/api/profiles/me/', methods=['PUT']) @jwt_to_session_cookie @login_required def api_profiles_me_id(player_id): if not request.stream: return '', 400 if current_user.player_id != player_id: return '', 401 profile_dir = '%s/%s' % (STORAGE_DIR, player_id) profile = profile_pb2.PlayerProfile() profile_file = '%s/profile.bin' % profile_dir with open(profile_file, 'rb') as fd: profile.ParseFromString(fd.read()) #update profile from json profile.country_code = request.json['countryCode'] profile.dob = request.json['dob'] profile.email = request.json['emailAddress'] profile.first_name = request.json['firstName'] profile.last_name = request.json['lastName'] profile.height_in_millimeters = request.json['height'] profile.is_male = request.json['male'] profile.use_metric = request.json['useMetric'] profile.weight_in_grams = request.json['weight'] image = imageSrc(player_id) if image is not None: profile.large_avatar_url = image with open(profile_file, 'wb') as fd: fd.write(profile.SerializeToString()) if MULTIPLAYER: current_user.first_name = profile.first_name current_user.last_name = profile.last_name db.session.commit() return api_profiles_me() @app.route('/api/profiles/', methods=['PUT']) @app.route('/api/profiles//in-game-fields', methods=['PUT']) @jwt_to_session_cookie @login_required def api_profiles_id(player_id): if not request.stream: return '', 400 if player_id == 0: return '', 400 # can't return 401 to /api/profiles/0/in-game-fields (causes issues in following requests) if current_user.player_id != player_id: return '', 401 stream = request.stream.read() with open('%s/%s/profile.bin' % (STORAGE_DIR, player_id), 'wb') as f: f.write(stream) if MULTIPLAYER: profile = profile_pb2.PlayerProfile() profile.ParseFromString(stream) current_user.first_name = profile.first_name current_user.last_name = profile.last_name db.session.commit() return '', 204 @app.route('/api/profiles//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//activities', methods=['GET', 'POST'], strict_slashes=False) @jwt_to_session_cookie @login_required def api_profiles_activities(player_id): if request.method == 'POST': if not request.stream: return '', 400 if current_user.player_id != player_id: return '', 401 activity = activity_pb2.Activity() activity.ParseFromString(request.stream.read()) activity.id = insert_protobuf_into_db(Activity, activity, ['fit']) return '{"id": %ld}' % activity.id, 200 # request.method == 'GET' activities = activity_pb2.ActivityList() rows = db.session.execute(sqlalchemy.text("SELECT * FROM activity WHERE player_id = :p AND date > date('now', '-1 month')"), {"p": player_id}).mappings() for row in rows: activity = activities.activities.add() row_to_protobuf(row, activity, exclude_fields=['fit']) return activities.SerializeToString(), 200 def time_since(date): seconds = (world_time() - date) // 1000 interval = seconds // 31536000 if interval > 0: interval_type = 'year' else: interval = seconds // 2592000 if interval > 0: interval_type = 'month' else: interval = seconds // 604800 if interval > 0: interval_type = 'week' else: interval = seconds // 86400 if interval > 0: interval_type = 'day' else: interval = seconds // 3600 if interval > 0: interval_type = 'hour' else: interval = seconds // 60 if interval > 0: interval_type = 'minute' else: return 'Just now' if interval > 1: interval_type += 's' return '%s %s ago' % (interval, interval_type) def random_profile(p): p.ride_helmet_type = random.choice(GD['headgears']) p.glasses_type = random.choice(GD['glasses']) p.ride_shoes_type = random.choice(GD['bikeshoes']) p.ride_socks_type = random.choice(GD['socks']) p.ride_jersey = random.choice(GD['jerseys']) p.bike_wheel_rear, p.bike_wheel_front = random.choice(GD['wheels']) p.bike_frame = random.choice(list(GD['bikeframes'].keys())) p.run_shirt_type = random.choice(GD['runshirts']) p.run_shorts_type = random.choice(GD['runshorts']) p.run_shoes_type = random.choice(GD['runshoes']) return p @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(random_profile(profile)) p.id = p_id p.first_name = '' p.last_name = time_since(global_ghosts[player_id].play[ghostId-1].date) p.country_code = 0 else: if p_id in global_pace_partners.keys(): profile = global_pace_partners[p_id].profile elif p_id in global_bots.keys(): profile = global_bots[p_id].profile else: profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, p_id) if os.path.isfile(profile_file): try: with open(profile_file, 'rb') as fd: profile.ParseFromString(fd.read()) except Exception as exc: logger.warning('api_profiles: %s' % repr(exc)) profiles.profiles.append(profile) return profiles.SerializeToString(), 200 @app.route('/api/player-playbacks/player/playback', methods=['POST']) @jwt_to_session_cookie @login_required def player_playbacks_player_playback(): pb_dir = '%s/playbacks' % STORAGE_DIR try: if not os.path.isdir(pb_dir): os.makedirs(pb_dir) except IOError as e: logger.error("failed to create playbacks dir (%s): %s", pb_dir, str(e)) return '', 400 stream = request.stream.read() pb = playback_pb2.PlaybackData() pb.ParseFromString(stream) if pb.time == 0: return '', 200 new_uuid = str(uuid.uuid4()) new_pb = Playback(player_id=current_user.player_id, uuid=new_uuid, segment_id=pb.segment_id, time=pb.time, world_time=pb.world_time, type=pb.type) db.session.add(new_pb) db.session.commit() with open('%s/%s.playback' % (pb_dir, new_uuid), 'wb') as f: f.write(stream) return new_uuid, 201 @app.route('/api/player-playbacks/player//playbacks//