diff --git a/cdn/static/web/launcher/profile.html b/cdn/static/web/launcher/profile.html new file mode 100644 index 0000000..84b5ef6 --- /dev/null +++ b/cdn/static/web/launcher/profile.html @@ -0,0 +1,44 @@ +{% extends "./layout.html" %} +{% block content %} +

Download Zwift profile

+

Logged in as {{ username }}

+
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + Back +
+
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} +
+
+{% endblock %} diff --git a/cdn/static/web/launcher/strava.html b/cdn/static/web/launcher/strava.html new file mode 100644 index 0000000..b9278f2 --- /dev/null +++ b/cdn/static/web/launcher/strava.html @@ -0,0 +1,23 @@ +{% extends "./layout.html" %} +{% block content %} +

Zwift Launcher

+ {% if username %} +

Logged in as {{ username }}

+ + {% endif %} +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} +
+
+{% endblock %} diff --git a/cdn/static/web/launcher/upload.html b/cdn/static/web/launcher/upload.html index c05761e..0fb5bb2 100644 --- a/cdn/static/web/launcher/upload.html +++ b/cdn/static/web/launcher/upload.html @@ -2,14 +2,15 @@ {% block content %}

Logged in as {{ username }} ({{ name }})

-
+
    {% if profile %}
  • - -   
    Download
    + + +
    Download
    Delete
  • @@ -18,7 +19,8 @@
  • - + +
    Delete
  • @@ -27,7 +29,18 @@
  • - + +
    Delete
    +
    +
    +
  • + {% endif %} + {% if zwift %} +
  • +
    +
    + +
    Delete
  • @@ -36,13 +49,13 @@
-
+

Upload file

- +
diff --git a/cdn/static/web/launcher/user_home.html b/cdn/static/web/launcher/user_home.html index 3170543..d41a198 100644 --- a/cdn/static/web/launcher/user_home.html +++ b/cdn/static/web/launcher/user_home.html @@ -15,6 +15,10 @@
Upload Change password + Strava auth + {% if server_ip %} + Get Zwift profile + {% endif %} Logout {% if is_admin and not restarting %} Restart server @@ -62,6 +66,17 @@
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • +
    {{ message }}
    +
  • + {% endfor %} +
+ {% endif %} + {% endwith %}
{% endblock %} diff --git a/online_sync.py b/online_sync.py new file mode 100755 index 0000000..e0bf98f --- /dev/null +++ b/online_sync.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 + +# +# Adapted from https://github.com/jlemon/zlogger/blob/master/get_riders.py +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Jonathan Lemon +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json +import requests +import protobuf.activity_pb2 as activity_pb2 +import protobuf.profile_pb2 as profile_pb2 + + +def post_credentials(session, username, password): + # Credentials POSTing and tokens retrieval + # POST https://secure.zwift.com/auth/realms/zwift/tokens/access/codes + + try: + response = session.post( + url="https://secure.zwift.com/auth/realms/zwift/tokens/access/codes", + headers={ + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Content-Type": "application/x-www-form-urlencoded", + "Host": "secure.zwift.com", + "User-Agent": "Zwift/1.5 (iPhone; iOS 9.0.2; Scale/2.00)", + "Accept-Language": "en-US;q=1", + }, + data={ + "client_id": "Zwift_Mobile_Link", + "username": username, + "password": password, + "grant_type": "password", + }, + allow_redirects=False, + ) + + json_dict = json.loads(response.content) + + return (json_dict["access_token"], json_dict["refresh_token"], json_dict["expires_in"]) + + except requests.exceptions.RequestException as e: + print('HTTP Request failed: %s' % e) + flash('HTTP Request failed: %s' % e) + + except KeyError as e: + print('Invalid uname and/or password') + flash('Invalid uname and/or password') + + +def query_player_profile(session, access_token): + # Query Player Profile + # GET https://us-or-rly101.zwift.com/api/profiles/ + try: + response = session.get( + url="https://us-or-rly101.zwift.com/api/profiles/me", + headers={ + "Accept-Encoding": "gzip, deflate", + "Accept": "application/x-protobuf-lite", + "Connection": "keep-alive", + "Host": "us-or-rly101.zwift.com", + "User-Agent": "Zwift/115 CFNetwork/758.0.2 Darwin/15.0.0", + "Authorization": "Bearer %s" % access_token, + "Accept-Language": "en-us", + }, + ) + + return response.content + + except requests.exceptions.RequestException as e: + print('HTTP Request failed: %s' % e) + + +def logout(session, refresh_token): + # Logout + # POST https://secure.zwift.com/auth/realms/zwift/tokens/logout + try: + response = session.post( + url="https://secure.zwift.com/auth/realms/zwift/tokens/logout", + headers={ + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "Content-Type": "application/x-www-form-urlencoded", + "Host": "secure.zwift.com", + "User-Agent": "Zwift/1.5 (iPhone; iOS 9.0.2; Scale/2.00)", + "Accept-Language": "en-US;q=1", + }, + data={ + "client_id": "Zwift_Mobile_Link", + "refresh_token": refresh_token, + }, + ) + + except requests.exceptions.RequestException as e: + print('HTTP Request failed: %s' % e) + + +def login(session, user, password): + access_token, refresh_token, expired_in = post_credentials(session, user, password) + return access_token, refresh_token + +def upload_activity(session, access_token, activity): + try: + response = session.put( + url="https://us-or-rly101.zwift.com/api/profiles/%s/activities/%s" % (activity.player_id, activity.id), + headers={ + "Content-Type": "application/x-protobuf-lite", + "Accept": "application/json", + "Connection": "keep-alive", + "Host": "us-or-rly101.zwift.com", + "User-Agent": "Zwift/115 CFNetwork/758.0.2 Darwin/15.0.0", + "Authorization": "Bearer %s" % access_token, + "Accept-Language": "en-us", + }, + data=activity.SerializeToString(), + ) + return response.status_code + + except requests.exceptions.RequestException as e: + print('HTTP Request failed: %s' % e) + + +def get_player_id(session, access_token): + try: + response = session.get( + url="https://us-or-rly101.zwift.com/api/profiles/me", + headers={ + "Accept-Encoding": "gzip, deflate", + "Accept": "application/x-protobuf-lite", + "Connection": "keep-alive", + "Host": "us-or-rly101.zwift.com", + "User-Agent": "Zwift/115 CFNetwork/758.0.2 Darwin/15.0.0", + "Authorization": "Bearer %s" % access_token, + "Accept-Language": "en-us", + }, + ) + + profile = profile_pb2.Profile() + profile.ParseFromString(response.content) + return profile.id + + except requests.exceptions.RequestException as e: + print('HTTP Request failed: %s' % e) diff --git a/zwift_offline.py b/zwift_offline.py index 44c0694..c0f292f 100644 --- a/zwift_offline.py +++ b/zwift_offline.py @@ -14,6 +14,7 @@ import math import threading import re import smtplib, ssl +import requests from copy import copy from functools import wraps from io import BytesIO @@ -43,6 +44,7 @@ import protobuf.world_pb2 as world_pb2 import protobuf.zfiles_pb2 as zfiles_pb2 import protobuf.hash_seeds_pb2 as hash_seeds_pb2 import protobuf.events_pb2 as events_pb2 +import online_sync logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) logger = logging.getLogger('zoffline') @@ -92,7 +94,7 @@ else: SECRET_KEY_FILE = "%s/secret-key.txt" % STORAGE_DIR ENABLEGHOSTS_FILE = "%s/enable_ghosts.txt" % STORAGE_DIR MULTIPLAYER = False -garmin_key = None +credentials_key = None if os.path.exists("%s/multiplayer.txt" % STORAGE_DIR): MULTIPLAYER = True try: @@ -111,12 +113,20 @@ if os.path.exists("%s/multiplayer.txt" % STORAGE_DIR): logger.warn("cryptography is not installed. Uploaded garmin_credentials.txt will not be encrypted.") encrypt = False if encrypt: - GARMIN_KEY_FILE = "%s/garmin-key.txt" % STORAGE_DIR - if not os.path.exists(GARMIN_KEY_FILE): - with open(GARMIN_KEY_FILE, 'wb') as f: + CREDENTIALS_KEY_FILE = "%s/credentials-key.txt" % STORAGE_DIR + if not os.path.exists(CREDENTIALS_KEY_FILE): + with open(CREDENTIALS_KEY_FILE, 'wb') as f: f.write(Fernet.generate_key()) - with open(GARMIN_KEY_FILE, 'rb') as f: - garmin_key = f.read() + with open(CREDENTIALS_KEY_FILE, 'rb') as f: + credentials_key = f.read() + +try: + with open('%s/strava-client.txt' % STORAGE_DIR, 'r') as f: + client_id = f.readline().rstrip('\r\n') + client_secret = f.readline().rstrip('\r\n') +except: + client_id = '28117' + client_secret = '41b7b7b76d8cfc5dc12ad5f020adfea17da35468' from tokens import * @@ -457,11 +467,93 @@ def reset(username): return render_template("reset.html", username=current_user.username) +@app.route("/strava", methods=['GET']) +@login_required +def strava(): + try: + from stravalib.client import Client + except ImportError: + flash("stravalib is not installed. Skipping Strava authorization attempt.") + return redirect('/user/%s/' % current_user.username) + client = Client() + url = client.authorization_url(client_id=client_id, + redirect_uri='https://%s/authorization' % server_ip, + scope='activity:write') + return redirect(url) + + +@app.route("/authorization", methods=["GET", "POST"]) +@login_required +def authorization(): + from stravalib.client import Client + try: + client = Client() + code = request.args.get('code') + token_response = client.exchange_code_for_token(client_id=client_id, client_secret=client_secret, code=code) + with open('%s/strava_token.txt' % os.path.join(STORAGE_DIR, str(current_user.player_id)), 'w') as f: + f.write(client_id + '\n'); + f.write(client_secret + '\n'); + f.write(token_response['access_token'] + '\n'); + f.write(token_response['refresh_token'] + '\n'); + f.write(str(token_response['expires_at']) + '\n'); + flash("Strava authorized. Go to \"Upload\" to remove authorization.") + except: + flash("Strava canceled.") + flash("Please close this window and return to Zwift Launcher.") + return render_template("strava.html", username=current_user.username) + + +@app.route("/profile//", methods=["GET", "POST"]) +@login_required +def profile(username): + if request.method == "POST": + if request.form['username'] == "" or request.form['password'] == "": + flash("Zwift credentials can't be empty") + return render_template("profile.html", username=current_user.username) + + username = request.form['username'] + password = request.form['password'] + player_id = current_user.player_id + profile_dir = '%s/%s' % (STORAGE_DIR, str(player_id)) + session = requests.session() + + try: + access_token, refresh_token = online_sync.login(session, username, password) + try: + profile = online_sync.query_player_profile(session, access_token) + with open('%s/profile.bin' % SCRIPT_DIR, 'wb') as f: + f.write(profile) + online_sync.logout(session, refresh_token) + os.rename('%s/profile.bin' % SCRIPT_DIR, '%s/profile.bin' % profile_dir) + flash("Zwift profile installed locally.") + except: + flash("Error downloading profile") + if request.form.get("safe_zwift", None) != None: + try: + file_path = os.path.join(profile_dir, 'zwift_credentials.txt') + with open(file_path, 'w') as f: + f.write(username + '\n'); + f.write(password + '\n'); + if credentials_key is not None: + with open(file_path, 'rb') as fr: + zwift_credentials = fr.read() + cipher_suite = Fernet(credentials_key) + ciphered_text = cipher_suite.encrypt(zwift_credentials) + with open(file_path, 'wb') as fw: + fw.write(ciphered_text) + flash("Zwift credentials saved") + except: + flash("Error saving 'zwift_credentiasl.txt' file") + except: + flash("Error invalid Username or password") + return render_template("profile.html", username=current_user.username) + + @app.route("/user//") @login_required def user_home(username): return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), - online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes) + online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes, server_ip=os.path.exists(SERVER_IP_FILE)) def send_message_to_all_online(message, sender='Server'): @@ -556,16 +648,23 @@ def upload(username): if request.method == 'POST': uploaded_file = request.files['file'] - if uploaded_file.filename in ['profile.bin', 'strava_token.txt', 'garmin_credentials.txt']: + if uploaded_file.filename in ['profile.bin', 'strava_token.txt', 'garmin_credentials.txt', 'zwift_credentials.txt']: file_path = os.path.join(profile_dir, uploaded_file.filename) uploaded_file.save(file_path) - if uploaded_file.filename == 'garmin_credentials.txt' and garmin_key is not None: + if uploaded_file.filename == 'garmin_credentials.txt' and credentials_key is not None: with open(file_path, 'rb') as fr: garmin_credentials = fr.read() - cipher_suite = Fernet(garmin_key) + cipher_suite = Fernet(credentials_key) ciphered_text = cipher_suite.encrypt(garmin_credentials) with open(file_path, 'wb') as fw: fw.write(ciphered_text) + if uploaded_file.filename == 'zwift_credentials.txt' and credentials_key is not None: + with open(file_path, 'rb') as fr: + garmin_credentials = fr.read() + cipher_suite = Fernet(credentials_key) + ciphered_text = cipher_suite.encrypt(garmin_credentials) + with open(file_path, 'wb') as fw: + fw.write(ciphered_text) flash("File %s uploaded." % uploaded_file.filename) else: flash("Invalid file name.") @@ -590,8 +689,13 @@ def upload(username): if os.path.isfile(garmin_file): stat = os.stat(garmin_file) garmin = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime)) + zwift = None + zwift_file = os.path.join(profile_dir, 'zwift_credentials.txt') + if os.path.isfile(zwift_file): + stat = os.stat(zwift_file) + zwift = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime)) - return render_template("upload.html", username=current_user.username, profile=profile, name=name, token=token, garmin=garmin) + return render_template("upload.html", username=current_user.username, profile=profile, name=name, token=token, garmin=garmin, zwift=zwift) @app.route("/download/profile.bin", methods=["GET"]) @@ -604,6 +708,17 @@ def download(): return send_file(profile_file, attachment_filename='profile.bin') +@app.route("/delete/", methods=["GET"]) +@login_required +def delete(filename): + player_id = current_user.player_id + profile_dir = os.path.join(STORAGE_DIR, str(player_id)) + delete_file = os.path.join(profile_dir, filename) + if os.path.isfile(delete_file): + os.remove("%s" %delete_file) + return redirect(url_for('upload', username=current_user)) + + @app.route("/logout/") @login_required def logout(username): @@ -1040,8 +1155,8 @@ def garmin_upload(player_id, activity): profile_dir = '%s/%s' % (STORAGE_DIR, player_id) try: with open('%s/garmin_credentials.txt' % profile_dir, 'r') as f: - if garmin_key is not None: - cipher_suite = Fernet(garmin_key) + if credentials_key is not None: + cipher_suite = Fernet(credentials_key) ciphered_text = f.read() unciphered_text = (cipher_suite.decrypt(ciphered_text.encode(encoding='UTF-8'))) unciphered_text = unciphered_text.decode(encoding='UTF-8') @@ -1067,6 +1182,57 @@ def garmin_upload(player_id, activity): logger.warn("Garmin upload failed. No internet?") +def zwift_upload(player_id): + profile_dir = '%s/%s' % (STORAGE_DIR, player_id) + SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR + if not os.path.exists(SERVER_IP_FILE): + logger.info("server_ip.txt missing, skip Zwift activity update") + return + try: + with open('%s/zwift_credentials.txt' % profile_dir, 'r') as f: + if credentials_key is not None: + cipher_suite = Fernet(credentials_key) + ciphered_text = f.read() + unciphered_text = (cipher_suite.decrypt(ciphered_text.encode(encoding='UTF-8'))) + unciphered_text = unciphered_text.decode(encoding='UTF-8') + split_credentials = unciphered_text.splitlines() + username = split_credentials[0] + password = split_credentials[1] + else: + username = f.readline().rstrip('\r\n') + password = f.readline().rstrip('\r\n') + except: + logger.warn("Failed to read %s/zwift_credentials.txt. Skipping Zwift upload attempt." % profile_dir) + return + + try: + session = requests.session() + try: + activity = activity_pb2.Activity() + access_token, refresh_token = online_sync.login(session, username, password) + activity.player_id = online_sync.get_player_id(session, access_token) + player_id = current_user.player_id + profile_dir = '%s/%s' % (STORAGE_DIR, str(player_id)) + activity_file = '%s/last_activity.bin' % profile_dir + if not os.path.isfile(activity_file): + print('Activity file not found') + with open(activity_file, 'rb') as fd: + try: + activity.ParseFromString(fd.read()) + except: + print('Could not parse activity file') + res = online_sync.upload_activity(session, access_token, activity) + if res == 200: + logger.info("Zwift activity upload succesfull") + else: + logger.warn("Zwift activity upload failed:%s:" %res) + online_sync.logout(session, refresh_token) + except: + logger.warn("Error uploading activity to Zwift Server") + except: + logger.warn("Zwift upload failed. No internet?") + + # With 64 bit ids Zwift can pass negative numbers due to overflow, which the flask int # converter does not handle so it's a string argument @app.route('/api/profiles//activities/', methods=['PUT']) @@ -1099,6 +1265,7 @@ def api_profiles_activities_id(player_id, activity_id): # For using with upload_activity.py (to upload zoffline activity to Zwift server) with open('%s/%s/last_activity.bin' % (STORAGE_DIR, player_id), 'wb') as f: f.write(activity.SerializeToString()) + zwift_upload(player_id) return response, 200 @app.route('/api/profiles//activities/0/rideon', methods=['POST']) #activity_id Seem to always be 0, even when giving ride on to ppl with 30km+