A web app for finding games that all of your friends own. https://whatcanweplay.net
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

632 lines
23KB

  1. # This file is a part of WhatCanWePlay
  2. # Copyright (C) 2020 TGRCDev
  3. # This program is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU Affero General Public License as published
  5. # by the Free Software Foundation, either version 3 of the License, or
  6. # (at your option) any later version.
  7. # This program is distributed in the hope that it will be useful,
  8. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. # GNU Affero General Public License for more details.
  11. # You should have received a copy of the GNU Affero General Public License
  12. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. try:
  14. import shutil, platform, os
  15. path_prefix = os.path.dirname(os.path.abspath(__file__))
  16. source = ""
  17. dest = ""
  18. if platform.system() == "Windows":
  19. source = "lib/wcwp_rust/target/release/whatcanweplay.dll"
  20. dest = "lib/bin/whatcanweplay.pyd"
  21. else:
  22. source = "lib/wcwp_rust/target/release/libwhatcanweplay.so"
  23. dest = "lib/bin/whatcanweplay.so"
  24. source = os.path.join(path_prefix, source)
  25. dest = os.path.join(path_prefix, dest)
  26. if not os.path.exists(dest):
  27. os.makedirs(os.path.dirname(dest), exist_ok=True)
  28. shutil.copy2(source, dest)
  29. else:
  30. source_time = os.path.getmtime(source)
  31. dest_time = os.path.getmtime(dest)
  32. try:
  33. if dest_time < source_time:
  34. print("Updating WhatCanWePlay rust library with newer library file...")
  35. shutil.copy2(source, dest)
  36. except Exception:
  37. print("Failed to update WhatCanWePlay rust library. It will be re-attempted when next launched.")
  38. from .lib.bin import whatcanweplay as wcwp
  39. except Exception as e:
  40. print("Failed to load WhatCanWePlay rust library. Please go to \"lib/wcwp_rust\" and run \"cargo build --release\"")
  41. print(e)
  42. raise e
  43. print("WhatCanWePlay rust lib loaded (" + str(wcwp.__file__) + ")")
  44. from flask import Flask, request, jsonify, Response, render_template, redirect, session, url_for, make_response
  45. import requests
  46. from urllib import parse
  47. from werkzeug.exceptions import BadRequest
  48. import json
  49. from requests import HTTPError
  50. import secrets
  51. from datetime import timezone, datetime, timedelta
  52. from itsdangerous import URLSafeSerializer
  53. from os import path
  54. import traceback
  55. # Load config
  56. def create_app():
  57. root_path = path.dirname(__file__)
  58. config = json.load(open(path.join(root_path, "config.json"), "r"))
  59. steam_key = config["steam-key"]
  60. igdb_key = config["igdb-client-id"]
  61. igdb_secret = config["igdb-secret"]
  62. debug = config.get("debug", config.get("DEBUG", False))
  63. enable_api_tests = config.get("enable-api-tests", debug)
  64. cookie_max_age_dict = config.get("cookie-max-age", {})
  65. cache_max_age_dict = config.get("igdb-cache-max-age", config.get("igdb-cache-info-age", {}))
  66. source_url = config.get("source-url", "")
  67. contact_email = config["contact-email"]
  68. privacy_email = config.get("privacy-email", contact_email)
  69. connect_timeout = config.get("connect-timeout", 0.0)
  70. commit_hash_filename = config.get("commit-hash-file", ".wcwp-commit-hash")
  71. donate_url = config.get("donate-url", "")
  72. if connect_timeout <= 0.0:
  73. connect_timeout = None
  74. read_timeout = config.get("read-timeout", 0.0)
  75. if read_timeout <= 0.0:
  76. read_timeout = None
  77. cache_file = config.get("igdb-cache-file")
  78. if not os.path.isabs(cache_file):
  79. cache_file = os.path.join(root_path, cache_file)
  80. # Create uWSGI callable
  81. app = Flask(__name__)
  82. app.debug = debug
  83. app.secret_key = config["secret-key"]
  84. # Hide requests to /steam_login to prevent linking Steam ID to IP in logs
  85. from werkzeug import serving
  86. parent_log_request = serving.WSGIRequestHandler.log_request
  87. def log_request(self, *args, **kwargs):
  88. if self.path.startswith("/steam_login"):
  89. self.log("info", "[request to /steam_login hidden]")
  90. return
  91. parent_log_request(self, *args, **kwargs)
  92. serving.WSGIRequestHandler.log_request = log_request
  93. # Setup cookie max_age
  94. cookie_max_age = timedelta(**cookie_max_age_dict).total_seconds()
  95. if cookie_max_age == 0:
  96. cookie_max_age = None
  97. # Setup cache info max age
  98. cache_max_age = 0.0
  99. if cache_file:
  100. cache_max_age = timedelta(**cache_max_age_dict).total_seconds()
  101. print("cookies set to expire after %f seconds" % cookie_max_age)
  102. print("cache set to expire after %f seconds" % cache_max_age)
  103. def fetch_and_store_commit_hash():
  104. f = open(commit_hash_filename, "w")
  105. import subprocess
  106. args = ['--git-dir=' + os.path.join(os.path.abspath(os.path.dirname(__file__)), ".git"), 'rev-parse', '--short', 'HEAD']
  107. try:
  108. try:
  109. commit_hash = subprocess.check_output(['git'] + args).decode("utf-8").strip()
  110. f.write(commit_hash)
  111. f.close()
  112. return commit_hash
  113. except:
  114. commit_hash = subprocess.check_output(['/usr/bin/git'] + args).decode("utf-8").strip()
  115. f.write(commit_hash)
  116. f.close()
  117. return commit_hash
  118. except:
  119. return ""
  120. @app.before_first_request
  121. def before_first_request():
  122. fetch_and_store_commit_hash()
  123. def get_commit_hash():
  124. try:
  125. f = open(commit_hash_filename, "r")
  126. hash = f.read()
  127. return hash
  128. except Exception:
  129. traceback.print_exc()
  130. return ""
  131. def basic_info_dict():
  132. email_rev = contact_email.split("@")
  133. basic_info = {
  134. "contact_email_user_reversed": email_rev[0][::-1],
  135. "contact_email_domain_reversed": email_rev[1][::-1],
  136. "source_url": source_url,
  137. "donate_url": donate_url
  138. }
  139. commit = get_commit_hash()
  140. if commit:
  141. basic_info["commit"] = commit
  142. return basic_info
  143. # Tries to fetch the Steam info cookie, returns an errcode and a dict
  144. #
  145. # Errcodes:
  146. # 0: no error, info returned. (will also return this if no cookie is present)
  147. # 1: bad cookie sig
  148. # 2: bad cookie JSON format
  149. # 3: info returned, but out of date (refresh recommended)
  150. def fetch_steam_cookie(request):
  151. cookie_str = request.cookies.get("steam_info")
  152. if not cookie_str:
  153. return 0, {}
  154. ser = URLSafeSerializer(app.secret_key)
  155. loaded, cookie_json = ser.loads_unsafe(cookie_str)
  156. if not loaded:
  157. return 1, {}
  158. try:
  159. steam_info = json.loads(cookie_json)
  160. except json.JSONDecodeError:
  161. return 2, {}
  162. if "expires" not in steam_info.keys() or steam_info["expires"] <= datetime.now(timezone.utc).timestamp():
  163. return 3, steam_info
  164. else:
  165. return 0, steam_info
  166. def refresh_steam_cookie(steamid: int, response):
  167. if steamid <= 0:
  168. response.set_cookie("steam_info", "", secure=True, httponly=True)
  169. return {}
  170. info = {}
  171. try:
  172. info = wcwp.steam.get_steam_users_info(steam_key, [steamid])[0]
  173. except IndexError:
  174. response.set_cookie("steam_info", "", secure=True, httponly=True)
  175. return {}
  176. except Exception:
  177. traceback.print_exc()
  178. response.set_cookie("steam_info", "", secure=True, httponly=True)
  179. return {}
  180. ser = URLSafeSerializer(app.secret_key)
  181. response.set_cookie(
  182. "steam_info",
  183. ser.dumps(json.dumps(info)),
  184. secure=True,
  185. httponly=True,
  186. max_age=cookie_max_age
  187. )
  188. return info
  189. @app.route('/')
  190. def index():
  191. errcode, steam_info = fetch_steam_cookie(request)
  192. response = Response()
  193. if errcode == 3:
  194. steam_info = refresh_steam_cookie(steam_info.get("steam_id", -1), response)
  195. elif errcode != 0:
  196. response.set_cookie("steam_info", "", secure=True, httponly=True)
  197. steam_info = {}
  198. response.data = render_template("home.html", steam_info=steam_info, **basic_info_dict())
  199. return response
  200. @app.route("/privacy")
  201. def privacy():
  202. return render_template("privacy.html", privacy_email=privacy_email, **basic_info_dict())
  203. @app.route("/steam_login", methods=["GET", "POST"])
  204. def steam_login():
  205. if request.method == "POST":
  206. steam_openid_url = 'https://steamcommunity.com/openid/login'
  207. return_url = request.base_url
  208. params = {
  209. 'openid.ns': "http://specs.openid.net/auth/2.0",
  210. 'openid.identity': "http://specs.openid.net/auth/2.0/identifier_select",
  211. 'openid.claimed_id': "http://specs.openid.net/auth/2.0/identifier_select",
  212. 'openid.mode': 'checkid_setup',
  213. 'openid.return_to': return_url,
  214. 'openid.realm': return_url
  215. }
  216. param_string = parse.urlencode(params)
  217. auth_url = steam_openid_url + "?" + param_string
  218. return redirect(auth_url)
  219. response = redirect(url_for("index"))
  220. response.headers["X-Robots-Tag"] = "none"
  221. if validate_steam_identity(dict(request.args)):
  222. steam_id = int(request.args["openid.identity"].rsplit("/")[-1])
  223. refresh_steam_cookie(steam_id, response)
  224. return response
  225. @app.route("/steam_logout")
  226. def steam_logout():
  227. response = redirect(url_for("index"))
  228. response.headers["X-Robots-Tag"] = "none"
  229. response.set_cookie("steam_info", "", secure=True, httponly=True)
  230. return response
  231. def validate_steam_identity(params):
  232. try:
  233. steam_login_url = "https://steamcommunity.com/openid/login"
  234. params["openid.mode"] = "check_authentication"
  235. r = requests.post(steam_login_url, data=params)
  236. if "is_valid:true" in r.text:
  237. return True
  238. return False
  239. except:
  240. return False
  241. # API stuff
  242. # Get the Friend List of the currently signed in user
  243. # Returns: A list of strings of Steam IDs on this users' friends list
  244. @app.route("/api/v1/get_friend_list", methods=["GET", "POST"] if enable_api_tests else ["POST"])
  245. def get_friend_list_v1():
  246. if request.method == "GET":
  247. return render_template(
  248. "api_test.html",
  249. api_function_name="get_friend_list",
  250. api_version="v1",
  251. api_function_params=[],
  252. steam_info=fetch_steam_cookie(request)[1],
  253. **basic_info_dict()
  254. )
  255. errcode, steam_info = fetch_steam_cookie(request)
  256. if "steam_id" not in steam_info.keys():
  257. return (
  258. "Not signed in to Steam. Please refresh the page.",
  259. 403
  260. )
  261. try:
  262. friends_info = wcwp.steam.get_friend_list(
  263. steam_key,
  264. steam_info["steam_id"]
  265. )
  266. for user in friends_info:
  267. if "steam_id" in user.keys():
  268. user["steam_id"] = str(user["steam_id"])
  269. user["exists"] = True
  270. return jsonify(friends_info)
  271. except wcwp.steam.BadWebkeyException:
  272. traceback.print_exc()
  273. return (
  274. "Site has bad Steam API key. Please contact us about this error at " + contact_email,
  275. 500
  276. )
  277. except wcwp.steam.ServerErrorException:
  278. traceback.print_exc()
  279. return (
  280. "Steam had an internal server error. Please try again later.",
  281. 500
  282. )
  283. except wcwp.steam.BadResponseException:
  284. traceback.print_exc()
  285. return (
  286. "Steam returned an unparseable response. Please try again later.",
  287. 500
  288. )
  289. except wcwp.steam.FriendListPrivateException:
  290. traceback.print_exc()
  291. return (
  292. "WhatCanWePlay cannot retrieve your friend list. Please change your friend list visibility to public and refresh the page.",
  293. 500
  294. )
  295. except Exception:
  296. traceback.print_exc()
  297. if debug:
  298. return (
  299. traceback.format_exc(),
  300. 500
  301. )
  302. else:
  303. traceback.print_exc()
  304. return (
  305. "An unknown error has occurred. Please try again later.",
  306. 500
  307. )
  308. def refresh_igdb_token():
  309. try:
  310. token_path = path.join(root_path, "bearer-token.json")
  311. token = wcwp.igdb.fetch_twitch_token(igdb_key, igdb_secret)
  312. token["expiry"] = datetime.now(timezone.utc).timestamp() + token.get("expires_in", 0)
  313. token.pop("expires_in")
  314. json.dump(token, open(token_path, "w"))
  315. return token.get("access_token", "")
  316. except Exception:
  317. traceback.print_exc()
  318. return ""
  319. def get_igdb_token():
  320. try:
  321. token_path = path.join(root_path, "bearer-token.json")
  322. if path.exists(token_path):
  323. token_file = open(token_path)
  324. token = json.load(token_file)
  325. token_file.close()
  326. if datetime.now(timezone.utc).timestamp() >= token.get("expiry", 0):
  327. return refresh_igdb_token()
  328. else:
  329. return token.get("access_token", "")
  330. else:
  331. return refresh_igdb_token()
  332. except Exception:
  333. traceback.print_exc()
  334. return ""
  335. cache_init_query = """
  336. CREATE TABLE IF NOT EXISTS game (
  337. steam_id INTEGER PRIMARY KEY,
  338. igdb_id INTEGER,
  339. name STRING,
  340. supported_players INTEGER DEFAULT(0),
  341. cover_id STRING,
  342. has_multiplayer BOOLEAN,
  343. expiry REAL DEFAULT(0.0)
  344. );
  345. """
  346. CACHE_VERSION = 1
  347. def initialize_cache():
  348. import sqlite3
  349. cache = sqlite3.connect(cache_file)
  350. cache.execute(cache_init_query)
  351. #cache.execute("PRAGMA user_version = ?;", [CACHE_VERSION]) # Doesn't work?
  352. cache.execute("PRAGMA user_version = %d" % CACHE_VERSION)
  353. return cache
  354. def cache_is_correct_version(cache):
  355. return cache.execute("PRAGMA user_version;").fetchone()[0] == CACHE_VERSION
  356. def update_cached_games(game_info):
  357. if not cache_file:
  358. return
  359. try:
  360. import sqlite3
  361. cache = None
  362. if os.path.exists(cache_file):
  363. cache = sqlite3.connect(cache_file)
  364. # Check if cache is correct version
  365. if not cache_is_correct_version(cache):
  366. # Cache is the wrong version, rebuild
  367. print("Cache file is the wrong version! Rebuilding... ")
  368. cache.close()
  369. os.remove(cache_file)
  370. cache = initialize_cache()
  371. else:
  372. cache = initialize_cache()
  373. insert_info = [
  374. [
  375. game.get("steam_id"),
  376. game.get("igdb_id"),
  377. game.get("name"),
  378. game.get("supported_players"),
  379. game.get("cover_id"),
  380. game.get("has_multiplayer"),
  381. datetime.now(timezone.utc).timestamp() + cache_max_age
  382. ] for game in game_info
  383. ]
  384. cache.executemany(
  385. "INSERT OR REPLACE INTO game VALUES (?,?,?,?,?,?,?);",
  386. insert_info
  387. )
  388. cache.commit()
  389. cache.close()
  390. except Exception:
  391. print("FAILED TO UPDATE CACHE DB")
  392. traceback.print_exc()
  393. return
  394. # returns [info of cached games], (set of uncached ids)
  395. def get_cached_games(steam_ids):
  396. if not cache_file:
  397. return [], set(steam_ids)
  398. game_info = []
  399. uncached = set(steam_ids)
  400. try:
  401. import sqlite3
  402. cache = sqlite3.connect(cache_file)
  403. cache.row_factory = sqlite3.Row
  404. if not cache_is_correct_version(cache):
  405. return [], set(steam_ids)
  406. query_str = "SELECT * FROM game WHERE steam_id IN (%s)" % ("?" + (",?" * (len(steam_ids) - 1))) # Construct a query with arbitrary parameter length
  407. cursor = cache.execute(
  408. query_str,
  409. steam_ids
  410. )
  411. for row in cursor.fetchall():
  412. game = dict(row)
  413. if datetime.now(timezone.utc).timestamp() < game.pop("expiry"):
  414. # Info hasn't expired
  415. game_info.append(game)
  416. uncached.remove(game["steam_id"])
  417. # Expired info gets updated during update_cached_games()
  418. except Exception:
  419. print("EXCEPTION THROWN WHILE QUERYING GAME CACHE!")
  420. traceback.print_exc()
  421. return [], set(steam_ids)
  422. return game_info, uncached
  423. # Errcodes
  424. # -1: An error occurred with a message. Additional fields: "message"
  425. # 0: No error
  426. # 1: User has private games list. Additional fields: "user"
  427. # 2: User has empty games list. Additional fields: "user"
  428. @app.route("/api/v1/intersect_owned_games", methods=["POST", "GET"] if enable_api_tests else ["POST"])
  429. def intersect_owned_games_v1():
  430. if request.method == "GET":
  431. params = [
  432. {"name": "steamids", "type":"csl:string"},
  433. {"name": "include_free_games", "type":"bool", "default": False}
  434. ]
  435. return render_template(
  436. "api_test.html",
  437. api_function_name="intersect_owned_games",
  438. api_version="v1",
  439. api_function_params=json.dumps(params),
  440. steam_info=fetch_steam_cookie(request)[1],
  441. **basic_info_dict()
  442. )
  443. errcode, steam_info = fetch_steam_cookie(request)
  444. if "steam_id" not in steam_info.keys():
  445. return (
  446. json.dumps({"message": "Not signed in to Steam. Please refresh the page and try again.", "errcode": -1}),
  447. 200
  448. )
  449. body = request.get_json(force=True, silent=True)
  450. if not isinstance(body, dict):
  451. return (
  452. json.dumps({"message": "Received a bad request. Please refresh the page and try again.", "errcode": -1}),
  453. 200
  454. )
  455. if not body or "steamids" not in body.keys():
  456. return (
  457. json.dumps({"message": "Received a bad request. Please refresh the page and try again.", "errcode": -1}),
  458. 200
  459. )
  460. try:
  461. steamids = set([int(id) for id in body["steamids"]])
  462. except (ValueError, TypeError):
  463. return (
  464. json.dumps({"message": "Received a bad request. Please refresh the page and try again.", "errcode": -1}),
  465. 200
  466. )
  467. if len(steamids) < 2:
  468. return (
  469. json.dumps({"message": "Must have at least 2 users to intersect games.", "errcode": -1}),
  470. 200
  471. )
  472. if len(steamids) > 10:
  473. return (
  474. json.dumps({"message": "Games intersection is capped at 10 users.", "errcode": -1}),
  475. 200
  476. )
  477. try:
  478. token = get_igdb_token()
  479. game_ids = wcwp.steam.intersect_owned_game_ids(steam_key, list(steamids))
  480. fetched_game_count = 0
  481. cached_game_count = 0
  482. game_info = []
  483. if game_ids:
  484. game_info, uncached_ids = get_cached_games(game_ids)
  485. cached_game_count = len(game_info)
  486. if uncached_ids:
  487. fetched_info, not_found = wcwp.igdb.get_steam_game_info(igdb_key, token, list(uncached_ids))
  488. cache_info_update = fetched_info
  489. if not_found:
  490. for uncached_id in [id for id in not_found]:
  491. cache_info_update.append({"steam_id": uncached_id}) # Cache empty data to prevent further IGDB fetch attempts
  492. update_cached_games(cache_info_update) # TODO: Spin up separate process for caching?
  493. game_info += fetched_info
  494. fetched_game_count = len(fetched_info)
  495. print("Intersection resulted in %d games (%d from cache, %d from IGDB)" % (len(game_info), cached_game_count, fetched_game_count))
  496. return jsonify({
  497. "message": "Intersected successfully",
  498. "games": game_info,
  499. "errcode": 0
  500. })
  501. except wcwp.steam.BadWebkeyException:
  502. traceback.print_exc()
  503. return (
  504. json.dumps({"message": "Site has bad Steam API key. Please contact us about this error at " + contact_email, "errcode": -1}),
  505. 500
  506. )
  507. except wcwp.steam.ServerErrorException:
  508. traceback.print_exc()
  509. return (
  510. json.dumps({"message": "Steam had an internal server error. Please try again later.", "errcode": -1}),
  511. 500
  512. )
  513. except wcwp.steam.BadResponseException:
  514. traceback.print_exc()
  515. return (
  516. json.dumps({"message": "Steam returned an unparseable response. Please try again later.", "errcode": -1}),
  517. 500
  518. )
  519. except wcwp.steam.GamesListPrivateException as e:
  520. if debug:
  521. print(e)
  522. else:
  523. print("Intersection interrupted due to private games list")
  524. return (
  525. json.dumps({"errcode": 1, "user": str(e.args[1])}),
  526. 500
  527. )
  528. except wcwp.steam.GamesListEmptyException as e:
  529. if debug:
  530. print(e)
  531. else:
  532. print("Intersection interrupted due to private games list")
  533. return (
  534. json.dumps({"errcode": 2, "user": str(e.args[1])}),
  535. 500
  536. )
  537. except Exception:
  538. traceback.print_exc()
  539. if debug:
  540. return (
  541. json.dumps({"message": traceback.format_exc(), "errcode": -1}),
  542. 500
  543. )
  544. else:
  545. return (
  546. json.dumps({"message": "An unknown error has occurred. Please try again later.", "errcode": -1}),
  547. 500
  548. )
  549. return app