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