Merge pull request #9 from Argon2000/mp

This commit is contained in:
oldnapalm
2020-11-10 17:39:46 -03:00
committed by GitHub
8 changed files with 159 additions and 123 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
*.pyc
storage/*
!storage/force_dir_in_git.txt
example-requests/*
example-requests/*
zoffline.log
migrate_auth.sql
+1 -1
View File
@@ -4,7 +4,7 @@ MAINTAINER zoffline <zoffline@tutanota.com>
WORKDIR /usr/src/app
RUN apk add --no-cache git
RUN pip install flask protobuf protobuf3_to_dict stravalib garmin-uploader
RUN pip install flask flask_sqlalchemy flask-login pyjwt protobuf protobuf3_to_dict stravalib
RUN git clone --depth 1 https://github.com/zoffline/zwift-offline
RUN chmod 777 zwift-offline/storage
+10 -12
View File
@@ -10,41 +10,39 @@
</head>
<body>
<div class="container-fluid">
<h1>Sign up</h1>
<h3>Sign up</h2>
<form id="signup" action="{{ url_for('signup') }}" method="post">
<div class="row">
<div class="col-md-4">
<label>Username</label>
<input type="text" id="username" name="username" class="form-control">
<input type="text" id="username" name="username" class="form-control form-control-sm">
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="col-xs-4 col-sm-4 col-md-4">
<label>Password</label>
<input type="password" id="password" name="password" class="form-control">
<input type="password" id="password" name="password" class="form-control form-control-sm">
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="col-xs-4 col-sm-4 col-md-4">
<label>Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control">
<input type="password" id="confirm_password" name="confirm_password" class="form-control form-control-sm">
</div>
</div>
<div class="row">
<div class="col-md-4">
<label>First name</label>
<input type="text" id="first_name" name="first_name" class="form-control">
<input type="text" id="first_name" name="first_name" class="form-control form-control-sm">
</div>
</div>
<div class="row">
<div class="col-md-4">
<label>Last name</label>
<input type="text" id="last_name" name="last_name" class="form-control">
<input type="text" id="last_name" name="last_name" class="form-control form-control-sm">
</div>
</div>
<div class="row">
<div class="col-md-12 top-buffer">
<input type="submit" value="Sign up" class="btn btn-outline-primary"><a href="{{ url_for('login') }}" class="btn btn-outline-danger">Back</a>
<input type="submit" value="Sign up" class="btn btn-sm btn-outline-primary"><a href="{{ url_for('login') }}" class="btn btn-sm btn-outline-danger">Back</a>
</div>
</div>
</form>
@@ -52,7 +50,7 @@
{% if messages %}
<ul class="list-group top-buffer">
{% for message in messages %}
<li class="list-group-item">{{ message }}</li>
<li class="list-group-item py-2">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
+5 -1
View File
@@ -18,7 +18,11 @@
profile.bin
</div>
<div class="col-md-3">
{{ profile }}
{% if profile %}
<a href="/download/profile.bin">{{ profile }}</a>
{% else %}
{{ profile }}
{% endif %}
</div>
</div>
</li>
+2 -2
View File
@@ -12,9 +12,9 @@ message SegmentResult {
optional string finish_time_str = 10;
required uint64 elapsed_ms = 11;
optional bool f12 = 12;
optional uint32 f13 = 13;
optional uint32 f13 = 13; //weight_in_grams
optional uint32 f14 = 14;
optional uint32 f15 = 15;
optional uint32 f15 = 15; //avg_power
optional bool f16 = 16;
optional string f17 = 17;
optional uint64 f18 = 18;
+1 -1
View File
@@ -16,7 +16,7 @@ MAPS = [ 'FRANCE' ] + [ 'INNSBRUCK' ] + [ 'LONDON' ] * 2 + [ 'NEWYORK' ] * 2 + [
dom = minidom.parseString('<MapSchedule><appointments></appointments><VERSION>1</VERSION></MapSchedule>')
appts = dom.getElementsByTagName('appointments')[0]
now = datetime.datetime.now()
now = datetime.datetime.utcnow()
prev_map = None
for i in range(0, 500):
map_choice = random.choice(MAPS)
+34 -17
View File
@@ -7,6 +7,7 @@ import sys
import threading
import time
import csv
from collections import deque
from datetime import datetime
from shutil import copyfile
if sys.version_info[0] > 2:
@@ -35,7 +36,7 @@ else:
PROXYPASS_FILE = "%s/cdn-proxy.txt" % STORAGE_DIR
SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR
MAP_OVERRIDE = None
MAP_OVERRIDE = deque(maxlen=16)
update_freq = 3
globalGhosts = {}
@@ -69,7 +70,7 @@ def saveGhost(name, player_id):
os.makedirs(folder)
except:
return
f = '%s/%s-%s.bin' % (folder, time.strftime("%Y-%m-%d-%H-%M-%S"), name)
f = '%s/%s-%s.bin' % (folder, zwift_offline.getUTCDateTime().strftime("%Y-%m-%d-%H-%M-%S"), name)
with open(f, 'wb') as fd:
fd.write(ghosts.rec.SerializeToString())
@@ -152,23 +153,16 @@ class CDNHandler(SimpleHTTPRequestHandler):
return fullpath
def do_GET(self):
global MAP_OVERRIDE
path_end = self.path.rsplit('/', 1)[1]
if path_end in ['FRANCE', 'INNSBRUCK', 'LONDON', 'NEWYORK', 'PARIS', 'RICHMOND', 'WATOPIA', 'YORKSHIRE']:
MAP_OVERRIDE = path_end
# We have no identifying information when Zwift makes MapSchedule request except for the client's IP.
MAP_OVERRIDE.append((self.client_address[0], path_end))
self.send_response(302)
self.send_header('Cookie', self.headers.get('Cookie') + "; map=%s" % path_end)
self.send_header('Location', 'https://secure.zwift.com/ride')
self.end_headers()
return
if MAP_OVERRIDE and self.path == '/gameassets/MapSchedule_v2.xml':
self.send_response(200)
self.send_header('Content-type', 'text/xml')
self.end_headers()
output = '<MapSchedule><appointments><appointment map="%s" start="%s"/></appointments><VERSION>1</VERSION></MapSchedule>' % (MAP_OVERRIDE, datetime.now().strftime("%Y-%m-%dT00:01-04"))
self.wfile.write(output.encode())
MAP_OVERRIDE = None
return
elif self.path == '/gameassets/MapSchedule_v2.xml' and os.path.exists(PROXYPASS_FILE):
if self.path == '/gameassets/MapSchedule_v2.xml' and os.path.exists(PROXYPASS_FILE):
# PROXYPASS_FILE existence indicates we know what we're doing and
# we can try to obtain the official map schedule. This can only work
# if we're running on a different machine than the Zwift client.
@@ -183,6 +177,17 @@ class CDNHandler(SimpleHTTPRequestHandler):
return
except:
pass # fallthrough to return zoffline version
elif self.path == '/gameassets/MapSchedule_v2.xml':
# Check if client requested the map be overridden
for override in MAP_OVERRIDE:
if override[0] == self.client_address[0]:
self.send_response(200)
self.send_header('Content-type', 'text/xml')
self.end_headers()
output = '<MapSchedule><appointments><appointment map="%s" start="%s"/></appointments><VERSION>1</VERSION></MapSchedule>' % (override[1], datetime.now().strftime("%Y-%m-%dT00:01-04"))
self.wfile.write(output.encode())
MAP_OVERRIDE.remove(override)
return
SimpleHTTPRequestHandler.do_GET(self)
@@ -243,7 +248,7 @@ class TCPHandler(socketserver.BaseRequestHandler):
msg.f11 = 1
payload = msg.SerializeToString()
lastAliveCheck = int(time.time())
lastAliveCheck = int(zwift_offline.getUTCTime())
while True:
#Check every 5 seconds for new updates
tcpthreadevent.wait(timeout=5)
@@ -259,11 +264,23 @@ class TCPHandler(socketserver.BaseRequestHandler):
for player_update_proto in playerUpdateQueue[player_id]:
player_update = message.updates.add()
player_update.ParseFromString(player_update_proto)
#Send if 10 updates has already been added and start a new message
if len(message.updates) > 9:
message_payload = message.SerializeToString()
self.request.sendall(struct.pack('!h', len(message_payload)))
self.request.sendall(message_payload)
message = udp_node_msgs_pb2.ServerToClient()
message.f1 = 1
message.player_id = player_id
message.world_time = zwift_offline.world_time()
added_player_updates.append(player_update_proto)
for player_update_proto in added_player_updates:
playerUpdateQueue[player_id].remove(player_update_proto)
t = int(time.time())
t = int(zwift_offline.getUTCTime())
#Check if any updates are added and should be sent to client, otherwise just keep alive every 25 seconds
if len(message.updates) > 0:
@@ -276,7 +293,7 @@ class TCPHandler(socketserver.BaseRequestHandler):
self.request.sendall(struct.pack('!h', len(payload)))
self.request.sendall(payload)
except Exception as e:
print('Exception: %s' % e)
print('Exception TCP: %s' % e)
break
class GhostsVariables:
@@ -325,7 +342,7 @@ class UDPHandler(socketserver.BaseRequestHandler):
ghosts.rec.player_id = player_id
organizeGhosts(player_id)
t = int(time.time())
t = int(zwift_offline.getUTCTime())
ghosts.lastPackageTime = t
if player_id in ghostsEnabled and ghostsEnabled[player_id]:
+103 -88
View File
@@ -18,7 +18,7 @@ from io import BytesIO
from shutil import copyfile
import jwt
from flask import Flask, request, jsonify, g, redirect, render_template, url_for, flash, session, abort
from flask import Flask, request, jsonify, g, redirect, render_template, url_for, flash, session, abort, make_response, send_file
from flask_login import UserMixin, AnonymousUserMixin, LoginManager, login_user, current_user, login_required
from google.protobuf.descriptor import FieldDescriptor
from protobuf_to_dict import protobuf_to_dict, TYPE_CALLABLE_MAP
@@ -37,8 +37,7 @@ import protobuf.world_pb2 as world_pb2
import protobuf.zfiles_pb2 as zfiles_pb2
import protobuf.hash_seeds_pb2 as hash_seeds_pb2
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logging.basicConfig(filename='zoffline.log', level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger('zoffline')
logger.setLevel(logging.WARN)
@@ -78,11 +77,9 @@ if os.path.exists("%s/multiplayer.txt" % STORAGE_DIR):
MULTIPLAYER = True
from tokens import *
AUTH_PATH = "%s/auth.db" % STORAGE_DIR
# 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=AUTH_PATH)
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:
@@ -152,6 +149,12 @@ coursesLookup = {
15: 'Paris'
}
def getUTCDateTime():
return datetime.datetime.utcnow()
def getUTCTime():
return getUTCDateTime().timestamp()
def getOnline():
onlineInRegion = Online()
for p_id in online:
@@ -337,6 +340,16 @@ def upload(username):
return render_template("upload.html", username=current_user.username, profile=profile, name=name, token=token)
@app.route("/download/profile.bin", methods=["GET"])
@login_required
def download():
player_id = current_user.player_id
profile_dir = os.path.join(STORAGE_DIR, str(player_id))
profile_file = os.path.join(profile_dir, 'profile.bin')
if os.path.isfile(profile_file):
return send_file(profile_file, attachment_filename='profile.bin')
@app.route("/logout/<username>")
def logout(username):
flash("Successfully logged out.")
@@ -353,13 +366,11 @@ type_callable_map[FieldDescriptor.TYPE_UINT64] = str
def insert_protobuf_into_db(table_name, msg):
cur = g.db.cursor()
msg_dict = protobuf_to_dict(msg, type_callable_map=type_callable_map)
columns = ', '.join(list(msg_dict.keys()))
placeholders = ':'+', :'.join(list(msg_dict.keys()))
query = 'INSERT INTO %s (%s) VALUES (%s)' % (table_name, columns, placeholders)
cur.execute(query, msg_dict)
g.db.commit()
db.engine.execute(query, msg_dict)
# XXX: can't be used to 'nullify' a column value
@@ -371,14 +382,12 @@ def update_protobuf_in_db(table_name, msg, id):
id = str(id)
except AttributeError:
pass
cur = g.db.cursor()
msg_dict = protobuf_to_dict(msg, type_callable_map=type_callable_map)
columns = ', '.join(list(msg_dict.keys()))
placeholders = ':'+', :'.join(list(msg_dict.keys()))
setters = ', '.join('{}=:{}'.format(key, key) for key in msg_dict)
query = 'UPDATE %s SET %s WHERE id=%s' % (table_name, setters, id)
cur.execute(query, msg_dict)
g.db.commit()
db.engine.execute(query, msg_dict)
def row_to_protobuf(row, msg, exclude_fields=[]):
@@ -397,19 +406,18 @@ def row_to_protobuf(row, msg, exclude_fields=[]):
# FIXME: I should really do this properly...
def get_id(table_name):
cur = g.db.cursor()
while True:
# I think activity id is actually only uint32. On the off chance it's
# int32, stick with 31 bits.
ident = int(random.getrandbits(31))
cur.execute("SELECT id FROM %s WHERE id = ?" % table_name, (str(ident),))
if not cur.fetchall():
row = db.engine.execute("SELECT id FROM %s WHERE id = %s" % (table_name, ident)).first()
if not row:
break
return ident
def world_time():
return int((time.time()-1414016075)*1000)
return int((getUTCTime()-1414016075)*1000)
@app.route('/api/auth', methods=['GET'])
@@ -425,7 +433,7 @@ def api_users_login():
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())
response.info.time = int(getUTCTime())
udp_node = response.info.nodes.node.add()
if os.path.exists(SERVER_IP_FILE):
with open(SERVER_IP_FILE, 'r') as f:
@@ -473,7 +481,7 @@ def api_zfiles():
zfile.id = int(random.getrandbits(31))
zfile.folder = "logfiles"
zfile.filename = "yep_took_good_care_of_that_file.txt"
zfile.timestamp = int(time.time())
zfile.timestamp = int(getUTCTime())
return zfile.SerializeToString(), 200
@@ -540,11 +548,9 @@ def api_profiles_me():
# However, without it, anyone "upgrading" to multiplayer mode will lose their existing data.
# TODO: need a warning in README that switching to multiplayer mode and back to single player will lose your existing data.
if profile.id != profile_id:
cur = g.db.cursor()
cur.execute('UPDATE activity SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
cur.execute('UPDATE goal SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
cur.execute('UPDATE segment_result SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
g.db.commit()
db.engine.execute('UPDATE activity SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
db.engine.execute('UPDATE goal SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
db.engine.execute('UPDATE segment_result SET player_id = ? WHERE player_id = ?', (str(profile_id), str(profile.id)))
profile.id = profile_id
elif current_user.player_id != profile.id:
# Update AnonUser's player_id to match
@@ -594,10 +600,9 @@ def api_profiles_activities(player_id):
# request.method == 'GET'
activities = activity_pb2.Activities()
cur = g.db.cursor()
# Select every column except 'fit' - despite being a blob python 3 treats it like a utf-8 string and tries to decode it
cur.execute("SELECT id, player_id, f3, name, f5, f6, start_date, end_date, distance, avg_heart_rate, max_heart_rate, avg_watts, max_watts, avg_cadence, max_cadence, avg_speed, max_speed, calories, total_elevation, strava_upload_id, strava_activity_id, f23, fit_filename, f29, date FROM activity WHERE player_id = ?", (str(player_id),))
for row in cur.fetchall():
rows = db.engine.execute("SELECT id, player_id, f3, name, f5, f6, start_date, end_date, distance, avg_heart_rate, max_heart_rate, avg_watts, max_watts, avg_cadence, max_cadence, avg_speed, max_speed, calories, total_elevation, strava_upload_id, strava_activity_id, f23, fit_filename, f29, date FROM activity WHERE player_id = ?", (str(player_id),))
for row in rows:
activity = activities.activities.add()
row_to_protobuf(row, activity, exclude_fields=['fit'])
a = activity
@@ -606,7 +611,7 @@ def api_profiles_activities(player_id):
#a.avg_watts == 0 and a.calories == 0 and a.distance == 0 and a.max_cadence == 0 and
#a.max_heart_rate == 0 and a.max_speed == 0 and a.max_watts == 0):
if a.distance == 0:
cur.executescript("DELETE FROM activity WHERE id = %s" % a.id)
db.engine.execute("DELETE FROM activity WHERE id = %s" % a.id)
activities.activities.remove(a)
return activities.SerializeToString(), 200
@@ -665,7 +670,7 @@ def strava_upload(player_id, activity):
logger.warn("Failed to read %s/strava_token.txt. Skipping Strava upload attempt." % profile_dir)
return
try:
if time.time() > int(expires_at):
if getUTCTime() > int(expires_at):
refresh_response = strava.refresh_access_token(client_id=client_id, client_secret=client_secret,
refresh_token=refresh_token)
with open('%s/strava_token.txt' % profile_dir, 'w') as f:
@@ -755,7 +760,7 @@ def api_profiles_activities_rideon(recieving_player_id):
player_update.type = 4 #ride on type
player_update.world_time1 = world_time()
player_update.world_time2 = player_update.world_time1 + 9890
player_update.f14 = int(time.time() * 1000000)
player_update.f14 = int(getUTCTime() * 1000000)
ride_on = udp_node_msgs_pb2.RideOn()
ride_on.rider_id = int(sending_player_id)
@@ -796,24 +801,22 @@ def get_month_range(dt):
def unix_time_millis(dt):
return int(dt.strftime('%s')) * 1000
return int(dt.timestamp()*1000)
def fill_in_goal_progress(goal, player_id):
cur = g.db.cursor()
now = datetime.datetime.now()
now = getUTCDateTime()
if goal.periodicity == 0: # weekly
first_dt, last_dt = get_week_range(now)
else: # monthly
first_dt, last_dt = get_month_range(now)
if goal.type == 0: # distance
cur.execute("""SELECT SUM(distance) FROM activity
distance = db.engine.execute("""SELECT SUM(distance) FROM activity
WHERE player_id = ?
AND strftime('%s', start_date) >= strftime('%s', ?)
AND strftime('%s', start_date) <= strftime('%s', ?)
AND end_date IS NOT NULL""",
(str(player_id), first_dt, last_dt))
distance = cur.fetchall()[0][0]
(str(player_id), first_dt, last_dt)).first()[0]
if distance:
goal.actual_distance = distance
goal.actual_duration = distance
@@ -822,14 +825,13 @@ def fill_in_goal_progress(goal, player_id):
goal.actual_duration = 0.0
else: # duration
cur.execute("""SELECT SUM(julianday(end_date) - julianday(start_date))
duration = db.engine.execute("""SELECT SUM(julianday(end_date) - julianday(start_date))
FROM activity
WHERE player_id = ?
AND strftime('%s', start_date) >= strftime('%s', ?)
AND strftime('%s', start_date) <= strftime('%s', ?)
AND end_date IS NOT NULL""",
(str(player_id), first_dt, last_dt))
duration = cur.fetchall()[0][0]
(str(player_id), first_dt, last_dt)).first()[0]
if duration:
goal.actual_duration = duration*1440 # convert from days to minutes
goal.actual_distance = duration*1440
@@ -857,7 +859,7 @@ def api_profiles_goals(player_id):
goal = goal_pb2.Goal()
goal.ParseFromString(request.stream.read())
goal.id = get_id('goal')
now = datetime.datetime.now()
now = getUTCDateTime()
goal.created_on = unix_time_millis(now)
set_goal_end_date(goal, now)
fill_in_goal_progress(goal, player_id)
@@ -867,14 +869,12 @@ def api_profiles_goals(player_id):
# request.method == 'GET'
goals = goal_pb2.Goals()
cur = g.db.cursor()
cur.execute("SELECT * FROM goal WHERE player_id = ?", (str(player_id),))
rows = cur.fetchall()
rows = db.engine.execute("SELECT * FROM goal WHERE player_id = ?", (str(player_id),))
for row in rows:
goal = goals.goals.add()
row_to_protobuf(row, goal)
end_dt = datetime.datetime.fromtimestamp(goal.period_end_date / 1000)
now = datetime.datetime.now()
now = getUTCDateTime()
if end_dt < now:
set_goal_end_date(goal, now)
update_protobuf_in_db('goal', goal, goal.id)
@@ -890,9 +890,7 @@ def api_profiles_goals_id(player_id, goal_id):
if player_id != current_user.player_id:
return '', 401
goal_id = int(goal_id) & 0xffffffffffffffff
cur = g.db.cursor()
cur.execute("DELETE FROM goal WHERE id = ?", (str(goal_id),))
g.db.commit()
db.engine.execute("DELETE FROM goal WHERE id = ?", (str(goal_id),))
return '', 200
@@ -923,7 +921,7 @@ def relay_worlds_generic(world_id=None):
#serializedMessage = chat_message.SerializeToString()
except:
#Not able to decode as playerupdate, send dummy response
world = { 'currentDateTime': int(time.time()),
world = { 'currentDateTime': int(getUTCTime()),
'currentWorldTime': world_time(),
'friendsInWorld': [],
'mapId': 1,
@@ -940,7 +938,7 @@ def relay_worlds_generic(world_id=None):
#PlayerUpdate
player_update.world_time2 = world_time() + 60000
player_update.f12 = 1
player_update.f14 = int(str(int(time.time()*1000000)))
player_update.f14 = int(str(int(getUTCTime()*1000000)))
for recieving_player_id in online.keys():
should_receive = False
if player_update.type == 5 or player_update.type == 105:
@@ -983,7 +981,7 @@ def relay_worlds_generic(world_id=None):
world.name = 'Public Watopia'
world.f3 = course
world.world_time = world_time()
world.real_time = int(time.time())
world.real_time = int(getUTCTime())
playersInRegion = 0
for p_id in online.keys():
player = online[p_id]
@@ -1084,16 +1082,13 @@ def relay_periodic_info():
return infos.SerializeToString(), 200
def add_segment_results(segment_id, player_id, only_best, from_date, to_date, results, only_own):
cur = g.db.cursor()
def add_segment_results(segment_id, player_id, only_best, from_date, to_date, results):
where_stmt = "WHERE segment_id = ?"
where_args = [str(segment_id)]
if only_own and player_id:
rows = None
if player_id:
where_stmt += " AND player_id = ?"
where_args.append(player_id)
elif not only_own and player_id:
where_stmt += " AND player_id != ?"
where_args.append(player_id)
if from_date:
where_stmt += " AND strftime('%s', finish_time_str) > strftime('%s', ?)"
where_args.append(from_date)
@@ -1101,9 +1096,18 @@ def add_segment_results(segment_id, player_id, only_best, from_date, to_date, re
where_stmt += " AND strftime('%s', finish_time_str) < strftime('%s', ?)"
where_args.append(to_date)
if only_best:
where_stmt += " ORDER BY elapsed_ms LIMIT 1"
cur.execute("SELECT * FROM segment_result %s" % where_stmt, where_args)
for row in cur.fetchall():
where_stmt += " AND world_time > ?"
#Only include results from max 1 hour ago
where_args.append(world_time()-(60*60*1000))
rows = db.engine.execute("""SELECT s1.* FROM segment_result s1
JOIN (SELECT s.player_id, MIN(Cast(s.elapsed_ms AS INTEGER)) AS min_time
FROM segment_result s %s GROUP BY s.player_id) s2 ON s2.player_id = s1.player_id AND s2.min_time = CAST(s1.elapsed_ms AS INTEGER)
GROUP BY s1.player_id, s1.elapsed_ms
ORDER BY CAST(s1.elapsed_ms AS INTEGER)
LIMIT 1000""" % where_stmt, where_args)
else:
rows = db.engine.execute("SELECT * FROM segment_result %s" % where_stmt, where_args)
for row in rows:
result = results.segment_results.add()
row_to_protobuf(row, result, ['f3', 'f4', 'segment_id', 'event_subgroup_id', 'finish_time_str', 'f14', 'f17', 'f18'])
@@ -1115,7 +1119,7 @@ def handle_segment_results(request):
result.ParseFromString(request.stream.read())
result.id = get_id('segment_result')
result.world_time = world_time()
result.finish_time_str = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
result.finish_time_str = getUTCDateTime().strftime("%Y-%m-%dT%H:%M:%SZ")
result.f20 = 0
insert_protobuf_into_db('segment_result', result)
return '{"id": %ld}' % result.id, 200
@@ -1138,12 +1142,10 @@ def handle_segment_results(request):
if player_id:
#Add players results
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results, True)
#Add top 100 other players results
#add_segment_results(segment_id, player_id, only_best, from_date, to_date, results, False)
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results)
else:
#Jersey, only 1 result, player_id = None
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results, False)
#Top 100 results, player_id = None
add_segment_results(segment_id, player_id, only_best, from_date, to_date, results)
return results.SerializeToString(), 200
@@ -1176,63 +1178,73 @@ def connect_db():
return conn
@app.before_request
def before_request():
g.db = connect_db()
@app.teardown_request
def teardown_request(exception):
if hasattr(g, 'db'):
g.db.close()
if exception != None:
print('Exception: %s' % exception)
def init_database():
conn = connect_db()
cur = conn.cursor()
if not os.path.exists(DATABASE_PATH) or not os.path.getsize(DATABASE_PATH):
# Create a new database
with open(DATABASE_INIT_SQL, 'r') as f:
cur.executescript(f.read())
cur.execute('INSERT INTO version VALUES (?)', (DATABASE_CUR_VER,))
conn.close()
db.engine.execute(f.read())
db.engine.execute('INSERT INTO version VALUES (?)', (DATABASE_CUR_VER,))
return
# Migrate database if necessary
if not os.access(DATABASE_PATH, os.W_OK):
logging.error("zwift-offline.db is not writable. Unable to upgrade database!")
return
cur_version = cur.execute('SELECT version FROM version')
version = cur.fetchall()[0][0]
version = db.engine.execute('SELECT version FROM version').first()[0]
if version == DATABASE_CUR_VER:
conn.close()
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())))
copyfile(DATABASE_PATH, "%s.v%d.%d.bak" % (DATABASE_PATH, version, int(getUTCTime())))
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())))
copyfile(DATABASE_PATH, "%s/zwift-offline.db.v%s.%d.bak" % (tempfile.gettempdir(), version, int(getUTCTime())))
except:
logging.warn("Failed to create a zoffline database backup prior to upgrading it.")
if version < 1:
# Adjust old world_time values in segment results to new rough estimate of Zwift's
logging.info("Upgrading zwift-offline.db to version 2")
cur.execute('UPDATE segment_result SET world_time = world_time-1414016075000')
cur.execute('UPDATE version SET version = 2')
db.engine.execute('UPDATE segment_result SET world_time = world_time-1414016075000')
db.engine.execute('UPDATE version SET version = 2')
if version == 1:
logging.info("Upgrading zwift-offline.db to version 2")
cur.execute('UPDATE segment_result SET world_time = cast(world_time/64.4131403573055-1414016075 as int)*1000')
cur.execute('UPDATE version SET version = 2')
db.engine.execute('UPDATE segment_result SET world_time = cast(world_time/64.4131403573055-1414016075 as int)*1000')
db.engine.execute('UPDATE version SET version = 2')
conn.commit()
conn.close()
def check_columns():
time.sleep(3)
rows = db.engine.execute(sqlalchemy.text("PRAGMA table_info(user)"))
should_have_columns = User.metadata.tables['user'].columns
current_columns = list()
for row in rows:
current_columns.append(row[1])
for column in should_have_columns:
if not column.name in current_columns:
nulltext = None
if column.nullable:
nulltext = "NULL"
else:
nulltext = "NOT NULL"
defaulttext = None
if column.default == None:
defaulttext = ""
else:
defaulttext = " DEFAULT %s" % column.default.arg
db.engine.execute(sqlalchemy.text("ALTER TABLE user ADD %s %s %s%s;" % (column.name, str(column.type), nulltext, defaulttext)))
@app.before_first_request
def before_first_request():
init_database()
check_columns()
db.create_all()
@@ -1320,6 +1332,7 @@ def auth_realms_zwift_protocol_openid_connect_token():
return FAKE_JWT, 200
@app.route("/start-zwift" , methods=['POST'])
@login_required
def start_zwift():
if MULTIPLAYER:
current_user.enable_ghosts = 'enableghosts' in request.form.keys()
@@ -1331,7 +1344,9 @@ def start_zwift():
if selected_map == 'CALENDAR':
return redirect("/ride", 302)
else:
return redirect("http://cdn.zwift.com/%s" % selected_map, 302)
response = make_response(redirect("http://cdn.zwift.com/%s" % selected_map, 302))
response.set_cookie('remember_token', request.cookies['remember_token'], domain=".zwift.com")
return response
# Called by Mac, but not Windows