def get(self, puzzle_id): "" ip = request.headers.get("X-Real-IP") user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(ip)) cur = db.cursor() result = cur.execute( fetch_query_string("select_viewable_puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall() if not result: # 404 if puzzle does not exist abort(404) (result, col_names) = rowify(result, cur.description) puzzle = result[0].get("puzzle") status = result[0].get("status") now = int(time.time()) count = (redis_connection.zcount( "timeline:{puzzle}".format(puzzle=puzzle), now - 5 * 60, "+inf") or 0) player_active_count = {"now": now, "count": count} cur.close() return json.jsonify(player_active_count)
def post(self, puzzle_id): "Ping and record the time in milliseconds for this player." now_ms = int(time.time() * 1000) response = {"message": "", "name": ""} user = current_app.secure_cookie.get(u"user") or user_id_from_ip( request.headers.get("X-Real-IP"), skip_generate=True) if user == None: response["message"] = "Player not currently logged in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) cur = db.cursor() # Validate the puzzle_id result = cur.execute( fetch_query_string("select_viewable_puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall() if not result: response["message"] = "Invalid puzzle id." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) else: (result, col_names) = rowify(result, cur.description) puzzle = result[0].get("puzzle") status = result[0].get("status") if status != ACTIVE: response["message"] = "Puzzle not active" response["name"] = "invalid" cur.close() db.commit() return make_response(json.jsonify(response), 200) # publish to the puzzle channel the ping with the user id. This will # allow that player to determine their latency. token = uuid.uuid4().hex[:4] pingtoken_key = get_pingtoken_key(puzzle, user, token) redis_connection.setex(pingtoken_key, 60, now_ms) current_app.logger.debug( "publish ping {puzzle_id}".format(puzzle_id=puzzle_id)) sse.publish( "{user}:{token}".format(user=user, token=token), type="ping", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) response["message"] = "ping accepted" response["name"] = "accepted" response = make_response(json.jsonify(response), 202) cur.close() db.commit() return response
def get(self, puzzle_id): """ deletePenalty: number; canFreeze: boolean; canDelete: boolean; canReset: boolean; hasActions: boolean; deleteDisabledMessage: string; //Not enough dots to delete this puzzle isFrozen: boolean; status: number; """ ip = request.headers.get("X-Real-IP") user = int(current_app.secure_cookie.get("user") or user_id_from_ip(ip)) cur = db.cursor() # validate the puzzle_id result = cur.execute( fetch_query_string("select-puzzle-details-for-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: # 400 if puzzle does not exist err_msg = { "msg": "No puzzle found", } cur.close() return make_response(json.jsonify(err_msg), 400) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] (delete_penalty, can_delete, delete_disabled_message) = self.get_delete_prereq( puzzleData ) response = { "canDelete": can_delete, "canFreeze": puzzleData.get("status") in (FROZEN, BUGGY_UNLISTED, ACTIVE), "canReset": puzzleData.get("permission") == PRIVATE and not puzzleData.get("is_original") and puzzleData.get("status") in (FROZEN, BUGGY_UNLISTED, ACTIVE, COMPLETED), "hasActions": puzzleData.get("status") in ( FROZEN, ACTIVE, COMPLETED, BUGGY_UNLISTED, RENDERING_FAILED, REBUILD, IN_RENDER_QUEUE, MAINTENANCE, ), "deleteDisabledMessage": delete_disabled_message, "deletePenalty": delete_penalty, "isFrozen": puzzleData.get("status") == FROZEN, "status": puzzleData.get("status", -99), } cur.close() return make_response(json.jsonify(response), 200)
def get(self): "" ip = request.headers.get("X-Real-IP") user = current_app.secure_cookie.get(u"user") or user_id_from_ip(ip) if user != None: user = int(user) args = {} if request.args: args.update(request.args.to_dict(flat=True)) count = args.get("count") if count == None: return make_response( encoder.encode({"msg": "missing count param"}), 400) count = int(count) if count > 45: return make_response( encoder.encode({"msg": "Count arg is too high"}), 400) cur = db.cursor() now = int(time.time()) active_players = frozenset( map( int, redis_connection.zrevrangebyscore("timeline", "+inf", now - ACTIVE_RANGE), )) rank_slice = redis_connection.zrevrange("rank", 0, -1, withscores=True) ranks = [] has_user_in_ranks = False user_in_ranks_index = -1 for index, item in enumerate(rank_slice): (player, score) = map(int, item) if not has_user_in_ranks and player == user: has_user_in_ranks = True user_in_ranks_index = len(ranks) if player in active_players or player == user: ranks.append({ "id": player, "score": score, }) ranks_near_user = [] if has_user_in_ranks: start = max(0, user_in_ranks_index - int(count / 2)) end = min(len(ranks), user_in_ranks_index + int(count / 2)) ranks_near_user = ranks[start:end] player_ranks = { "rank_slice": ranks_near_user, } cur.close() return encoder.encode(player_ranks)
def post(self): """Update shareduser to user""" # Prevent creating a new user if no support for cookies. Player should # have 'ot' already set by viewing the page. uses_cookies = current_app.secure_cookie.get(u"ot") if not uses_cookies: abort(400) cur = db.cursor() response = make_response("", 200) user = user_id_from_ip(request.headers.get("X-Real-IP")) if user == None: abort(400) user = int(user) # Only set new user if enough dots result = cur.execute( fetch_query_string("select-minimum-points-for-user.sql"), { "user": user, "points": current_app.config["NEW_USER_STARTING_POINTS"] + current_app.config["POINT_COST_FOR_CHANGING_BIT"], }, ).fetchone() if result: self.register_new_user(user) # Save as a cookie current_app.secure_cookie.set(u"user", str(user), response, expires_days=365) # Remove shareduser expires = datetime.datetime.utcnow() - datetime.timedelta(days=365) current_app.secure_cookie.set(u"shareduser", "", response, expires=expires) cur.execute( fetch_query_string("decrease-user-points.sql"), { "user": user, "points": current_app.config["POINT_COST_FOR_CHANGING_BIT"], }, ) cur.close() db.commit() return response
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) # Only allow valid contributor if args.get("contributor", None) != current_app.config.get("NEW_PUZZLE_CONTRIB"): abort(403) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if pieces < current_app.config["MINIMUM_PIECE_COUNT"]: abort(400) bg_color = check_bg_color(args.get("bg_color", "#808080")[:50]) user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP"))) # All puzzles are public by default, but allow the player to set to # PRIVATE if they have the role of membership. permission = int(args.get("permission", PUBLIC)) if permission not in (PUBLIC, PRIVATE): permission = PUBLIC description = escape(args.get("description", ""))[:1000] # Check link and validate link = url_fix(args.get("link", ""))[:1000] upload_file = request.files.get("upload_file", None) puzzle_id = submit_puzzle(pieces, bg_color, user, permission, description, link, upload_file) return redirect("/chill/site/front/{0}/".format(puzzle_id), code=303)
def get(self, puzzle_id): """ highestBid: number; canBump: boolean; hasActions: boolean; bumpDisabledMessage: string; //Not enough dots to delete this puzzle status: number; """ ip = request.headers.get("X-Real-IP") user = int(current_app.secure_cookie.get("user") or user_id_from_ip(ip)) cur = db.cursor() # validate the puzzle_id result = cur.execute( fetch_query_string("select-puzzle-details-for-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: # 400 if puzzle does not exist err_msg = { "msg": "No puzzle found", } cur.close() return make_response(json.jsonify(err_msg), 400) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] (highest_bid, can_bump, bump_disabled_message) = self.get_bump_prereq( cur, user, puzzleData ) response = { "canBump": can_bump, "hasActions": puzzleData.get("status") == IN_QUEUE, "bumpDisabledMessage": bump_disabled_message, "highestBid": highest_bid, "status": puzzleData.get("status", -99), } cur.close() return make_response(json.jsonify(response), 200)
def get(self): """ /newapi/player-puzzle-list/ returns { puzzles: [], } """ ip = request.headers.get("X-Real-IP") user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(ip)) cur = db.cursor() puzzle_list = [] result = cur.execute( fetch_query_string("select_available_player_puzzle_images.sql"), { "player": user }, ).fetchall() if result: (result, col_names) = rowify(result, cur.description) puzzle_list = list( filter(lambda puzzle: puzzle["puzzle_id"], result)) + list( filter(lambda puzzle: not puzzle["puzzle_id"], result)) puzzle_list = list(map(hidden_preview, puzzle_list)) response = { "puzzles": puzzle_list, } cur.close() return make_response(json.jsonify(response), 200)
def patch(self, puzzle_id): ip = request.headers.get("X-Real-IP") user = int(current_app.secure_cookie.get("user") or user_id_from_ip(ip)) # validate the args and headers args = {} xhr_data = request.get_json() if xhr_data: args.update(xhr_data) if request.form: args.update(request.form.to_dict(flat=True)) # Verify args action = args.get("action") if action not in ORIGINAL_ACTIONS: abort(400) cur = db.cursor() # validate the puzzle_id result = cur.execute( fetch_query_string("select-puzzle-details-for-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: # 400 if puzzle does not exist err_msg = { "msg": "No puzzle found", } cur.close() return make_response(json.jsonify(err_msg), 400) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] if not puzzleData["is_original"]: cur.close() abort(400) response = {} if action == "bump": bid_amount = self.get_bid_amount(cur, puzzleData) low, high = next( filter( lambda x: x[0] <= puzzleData["pieces"] and x[1] > puzzleData["pieces"], current_app.config["SKILL_LEVEL_RANGES"], ) ) # check if player has enough dots for bumping puzzle up in queue player_points_result = cur.execute( fetch_query_string("select-minimum-points-for-user.sql"), {"points": bid_amount, "user": user}, ).fetchone() if not player_points_result: cur.close() return make_response(json.jsonify({}), 400) # bump any puzzle that is currently at QUEUE_WINNING_BID to be QUEUE_BUMPED_BID cur.execute( fetch_query_string("bump-puzzle-winning-bid-to-bumped-bid.sql"), {"low": low, "high": high}, ) # bump this puzzle to be the winning bid and decrease player dots cur.execute( fetch_query_string("bump-puzzle-queue-winning-bid-for-puzzle.sql"), {"puzzle": puzzleData["id"]}, ) cur.execute( fetch_query_string("decrease-user-points.sql"), {"points": bid_amount, "user": user}, ) db.commit() else: cur.close() return make_response(json.jsonify({}), 400) cur.close() purge_route_from_nginx_cache( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id), current_app.config.get("PURGEURLLIST"), ) return make_response(json.jsonify(response), 202)
def post(self): "" response = {"message": "", "name": "error"} is_shareduser = False user = current_app.secure_cookie.get(u"user") if not user: user = user_id_from_ip(request.headers.get("X-Real-IP")) is_shareduser = True if user == None: response["message"] = "User not signed in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) args = {} if request.form: args.update(request.form.to_dict(flat=True)) email = args.get("email", "").strip().lower() if len(email) > EMAIL_MAXLENGTH: response["message"] = "E-mail is too long." response["name"] = "error" return make_response(json.jsonify(response), 400) cur = db.cursor() result = cur.execute(fetch_query_string("user-has-player-account.sql"), { "player_id": user }).fetchone() if not result or result[0] == 0: cur.execute( fetch_query_string("init-player-account-for-user.sql"), {"player_id": user}, ) result = cur.execute( fetch_query_string("select-player-details-for-player-id.sql"), { "player_id": user }, ).fetchall() if not result: # This shouldn't happen if user-has-player-account.sql response["message"] = "No player account." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) (result, col_names) = rowify(result, cur.description) existing_player_data = result[0] # Prevent shareduser changing the verified email address. if is_shareduser and existing_player_data["is_verifying_email"]: response[ "message"] = "A player on this same network has already submitted an email address. Changing it is not allowed until the account has been claimed or the verify email token expires." response["name"] = "error" return make_response(json.jsonify(response), 400) if existing_player_data["email"] == email: response[ "message"] = "No changes made to e-mail address ({}). The e-mail address is stored as lowercase.".format( email) response["name"] = "error" else: if email == "": cur.execute( fetch_query_string("remove-player-account-email.sql"), {"player_id": user}, ) response["message"] = "Removed e-mail from player account." response["name"] = "success" else: # Verify that email is unique result = cur.execute( fetch_query_string( "select-player-by-verified-email-address.sql"), { "email": email }, ).fetchall() if result: response[ "message"] = "A player has already registered this e-mail address." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) if existing_player_data["is_verifying_email"]: response[ "message"] = "An e-mail address is already in the process of verification for this player. Please wait." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) cur.execute( fetch_query_string("update-player-account-email.sql"), { "player_id": user, "email": email }, ) cur.execute( fetch_query_string( "update-player-account-email-verified.sql"), { "player_id": user, "email_verified": 0, }, ) # Send verification email (silent fail if not configured) token = uuid.uuid4().hex message = """ Please verify your e-mail address with Puzzle Massive by following the link below. http://{DOMAIN_NAME}/chill/site/claim-player/{token}/ Complete registering your e-mail address by visiting that web page and clicking the "verify player" button. You can ignore this message if you didn't initiate the request. """.format(token=token, DOMAIN_NAME=current_app.config.get("DOMAIN_NAME")) current_app.logger.debug(message) if not current_app.config.get("DEBUG", True): try: send_message( email, "Puzzle Massive - verify e-mail address", message, current_app.config, ) except Exception as err: current_app.logger.warning( "Failed to send verification message. email: {email}\n {message}\n error: {err}" .format(err=err, email=email, message=message)) pass cur.execute( fetch_query_string( "update-player-account-email-verify-token.sql"), { "player_id": user, "email_verify_token": token, "expire_token_timeout": "+1 hour", }, ) response[ "message"] = "Updated e-mail ({}) for player account. ".format( email) response["name"] = "success" db.commit() cur.close() return make_response(json.jsonify(response), 202)
def patch(self, puzzle_id, piece): """ args: x y r """ def _blockplayer(): timeouts = current_app.config["BLOCKEDPLAYER_EXPIRE_TIMEOUTS"] blocked_count_ip_key = f"blocked:{ip}" expire_index = max(0, redis_connection.incr(blocked_count_ip_key) - 1) redis_connection.expire(blocked_count_ip_key, timeouts[-1]) timeout = timeouts[min(expire_index, len(timeouts) - 1)] expires = now + timeout blockedplayers_for_puzzle_key = "blockedplayers:{puzzle}".format( puzzle=puzzle) # Add the player to the blocked players list for the puzzle and # extend the expiration of the key. redis_connection.zadd(blockedplayers_for_puzzle_key, {user: expires}) redis_connection.expire(blockedplayers_for_puzzle_key, timeouts[-1]) err_msg = get_blockedplayers_err_msg(expires, expires - now) sse.publish( "{user}:{piece}:{karma}:{karma_change}".format( user=user, piece=piece, karma=karma + recent_points, karma_change=karma_change, ), type="karma", channel="puzzle:{puzzle_id}".format( puzzle_id=puzzle_data["puzzle_id"]), ) return make_response(json.jsonify(err_msg), 429) ip = request.headers.get("X-Real-IP") validate_token = (len({"all", "valid_token"}.intersection( current_app.config["PUZZLE_RULES"])) > 0) user = None now = int(time.time()) # validate the args and headers args = {} xhr_data = request.get_json() if xhr_data: args.update(xhr_data) if request.form: args.update(request.form.to_dict(flat=True)) if len(list(args.keys())) == 0: err_msg = { "msg": "invalid args", "type": "invalid", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) # check if args are only in acceptable set if len(self.ACCEPTABLE_ARGS.intersection(set(args.keys()))) != len( list(args.keys())): err_msg = { "msg": "invalid args", "type": "invalid", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) # validate that all values are int for key, value in list(args.items()): if not isinstance(value, int): try: args[key] = int(value) except ValueError: err_msg = { "msg": "invalid args", "type": "invalid", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) x = args.get("x") y = args.get("y") r = args.get("r") snapshot_id = request.headers.get("Snap") # Token is to make sure puzzle is still in sync. # validate the token token = request.headers.get("Token") if not token: err_msg = { "msg": "Missing token", "type": "missing", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) mark = request.headers.get("Mark") if not mark: err_msg = { "msg": "Missing mark", "type": "missing", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) # start = time.perf_counter() existing_token = redis_connection.get(f"t:{mark}") if validate_token and existing_token: (m_puzzle, m_piece, m_user) = existing_token.split(":") user = int(m_user) else: user = current_app.secure_cookie.get("user") or user_id_from_ip( ip, validate_shared_user=False) if user is None: err_msg = { "msg": "Please reload the page.", "reason": "The player login was not found.", "type": "puzzlereload", "timeout": 300, } return make_response(json.jsonify(err_msg), 400) user = int(user) pzq_key = "pzq:{puzzle_id}".format(puzzle_id=puzzle_id) pzq_fields = [ "puzzle", "table_width", "table_height", "permission", "pieces", ] puzzle_data = dict( zip(pzq_fields, redis_connection.hmget(pzq_key, pzq_fields))) puzzle = puzzle_data.get("puzzle") if puzzle is None: req = requests.get( "http://{HOSTAPI}:{PORTAPI}/internal/puzzle/{puzzle_id}/details/" .format( HOSTAPI=current_app.config["HOSTAPI"], PORTAPI=current_app.config["PORTAPI"], puzzle_id=puzzle_id, ), ) if req.status_code >= 400: err_msg = {"msg": "puzzle not available", "type": "missing"} return make_response(json.jsonify(err_msg), req.status_code) try: result = req.json() except ValueError as err: err_msg = {"msg": "puzzle not available", "type": "missing"} return make_response(json.jsonify(err_msg), 500) if result.get("status") not in (ACTIVE, BUGGY_UNLISTED): err_msg = {"msg": "puzzle not available", "type": "missing"} return make_response(json.jsonify(err_msg), 404) puzzle_data = result puzzle_data["puzzle"] = result["id"] redis_connection.hmset( pzq_key, { "puzzle": puzzle_data["puzzle"], "table_width": puzzle_data["table_width"], "table_height": puzzle_data["table_height"], "permission": puzzle_data["permission"], "pieces": puzzle_data["pieces"], }, ) redis_connection.expire(pzq_key, 300) else: puzzle_data["puzzle"] = int(puzzle_data["puzzle"]) puzzle_data["table_width"] = int(puzzle_data["table_width"]) puzzle_data["table_height"] = int(puzzle_data["table_height"]) puzzle_data["permission"] = int(puzzle_data["permission"]) puzzle_data["pieces"] = int(puzzle_data["pieces"]) puzzle = int(puzzle_data["puzzle"]) puzzle_data["puzzle_id"] = puzzle_id puzzle_piece_token_key = get_puzzle_piece_token_key(puzzle, piece) validate_token = (len({"all", "valid_token"}.intersection( current_app.config["PUZZLE_RULES"])) > 0) if validate_token: token_and_mark = redis_connection.get(puzzle_piece_token_key) if token_and_mark: (valid_token, other_mark) = token_and_mark.split(":") # other_user = int(other_user) if token != valid_token: err_msg = increase_ban_time(user, TOKEN_INVALID_BAN_TIME_INCR) err_msg["reason"] = "Token is invalid" return make_response(json.jsonify(err_msg), 409) if mark != other_mark: err_msg = increase_ban_time(user, TOKEN_INVALID_BAN_TIME_INCR) err_msg["reason"] = "Player is invalid" return make_response(json.jsonify(err_msg), 409) else: err_msg = { "msg": "Token has expired", "type": "expiredtoken", "reason": "", } return make_response(json.jsonify(err_msg), 409) # Expire the token since it shouldn't be used again if validate_token: redis_connection.delete(puzzle_piece_token_key) redis_connection.delete(f"t:{mark}") if (len({"all", "piece_translate_rate"}.intersection( current_app.config["PUZZLE_RULES"])) > 0): err_msg = bump_count(user) if err_msg.get("type") == "bannedusers": return make_response(json.jsonify(err_msg), 429) # Check if piece will be moved to within boundaries if x and (x < 0 or x > puzzle_data["table_width"]): err_msg = { "msg": "Piece movement out of bounds", "type": "invalidpiecemove", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) if y and (y < 0 or y > puzzle_data["table_height"]): err_msg = { "msg": "Piece movement out of bounds", "type": "invalidpiecemove", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) # Check again if piece can be moved and hasn't changed since getting token has_y = redis_connection.hget( "pc:{puzzle}:{piece}".format(puzzle=puzzle, piece=piece), "y") if has_y is None: err_msg = {"msg": "piece not available", "type": "missing"} return make_response(json.jsonify(err_msg), 404) if redis_connection.sismember(f"pcfixed:{puzzle}", piece) == 1: # immovable err_msg = { "msg": "piece can't be moved", "type": "immovable", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) (_, _, _, origin_x, origin_y, _) = unpack_token(token) redis_connection.publish( f"enforcer_piece_translate:{puzzle}", f"{user}:{piece}:{origin_x}:{origin_y}:{x}:{y}", ) points_key = "points:{user}".format(user=user) recent_points = int(redis_connection.get(points_key) or "0") karma_key = init_karma_key(redis_connection, puzzle, ip, current_app.config) karma = int(redis_connection.get(karma_key)) karma_change = 0 current_app.logger.debug( f"user: {user} ip: {ip} karma: {karma} recent_points {recent_points}" ) if (len({"all", "puzzle_open_rate"}.intersection( current_app.config["PUZZLE_RULES"])) > 0): # Decrease recent points if this is a new puzzle that user hasn't moved pieces on yet in the last hour pzrate_key = "pzrate:{user}:{today}".format( user=user, today=datetime.date.today().isoformat()) if redis_connection.sadd(pzrate_key, puzzle) == 1: # New puzzle that player hasn't moved a piece on in the last hour. redis_connection.expire(pzrate_key, HOUR) if recent_points > 0: redis_connection.decr(points_key) if (len({"all", "piece_move_rate"}.intersection( current_app.config["PUZZLE_RULES"])) > 0): # Decrease karma if piece movement rate has passed threshold pcrate_key = f"pcrate:{puzzle}:{user}" moves = redis_connection.incr(pcrate_key) redis_connection.expire(pcrate_key, PIECE_MOVEMENT_RATE_TIMEOUT) if moves > PIECE_MOVEMENT_RATE_LIMIT: if karma > 0: karma = redis_connection.decr(karma_key) karma_change -= 1 if (len({"all", "hot_piece"}.intersection( current_app.config["PUZZLE_RULES"])) > 0): # Decrease karma when moving the same piece multiple times within # a minute. hotpc_key = f"hotpc:{puzzle}:{user}:{piece}" recent_move_count = redis_connection.incr(hotpc_key) redis_connection.expire(hotpc_key, HOT_PIECE_MOVEMENT_RATE_TIMEOUT) if recent_move_count > MOVES_BEFORE_PENALTY: if karma > 0: karma = redis_connection.decr(karma_key) karma_change -= 1 if (len({"all", "hot_spot"}.intersection( current_app.config["PUZZLE_RULES"])) > 0): # Decrease the karma for the player if the piece is in a hotspot. hotspot_piece_key = f"hotspot:{puzzle}:{user}:{piece}" hotspot_count = int(redis_connection.get(hotspot_piece_key) or "0") if hotspot_count > HOTSPOT_LIMIT: if karma > 0: karma = redis_connection.decr(karma_key) karma_change -= 1 if karma_change < 0: # Decrease recent points for a piece move that decreased karma if recent_points > 0 and karma_change < 0: recent_points = redis_connection.decr(points_key) if karma + recent_points <= 0: return _blockplayer() piece_move_timeout = current_app.config["PIECE_MOVE_TIMEOUT"] # Use a custom built and managed queue to prevent multiple processes # from running the attempt_piece_movement concurrently on the same # puzzle. pzq_current_key = "pzq_current:{puzzle}".format(puzzle=puzzle) pzq_next_key = "pzq_next:{puzzle}".format(puzzle=puzzle) # The attempt_piece_movement bumps the pzq_current by 1 pzq_next = redis_connection.incr(pzq_next_key, amount=1) # Set the expire in case it fails to reach expire in attempt_piece_movement. redis_connection.expire(pzq_current_key, piece_move_timeout + 2) redis_connection.expire(pzq_next_key, piece_move_timeout + 2) attempt_count = 0 attempt_timestamp = time.time() timeout = attempt_timestamp + piece_move_timeout while attempt_timestamp < timeout: pzq_current = int(redis_connection.get(pzq_current_key) or "0") if pzq_current == pzq_next - 1: try: snapshot_msg = None snapshot_karma_change = False if snapshot_id: snapshot_key = f"snap:{snapshot_id}" snapshot = redis_connection.get(snapshot_key) if snapshot: snapshot_list = snapshot.split(":") snapshot_pzq = int(snapshot_list.pop(0)) if snapshot_pzq != pzq_current: # Check if any adjacent pieces are within range of x, y, r # Within that list check if any have moved # With the first one that has moved that was within range attempt piece movement on that by using adjusted x, y, r snaps = list( map(lambda x: x.split("_"), snapshot_list)) adjacent_piece_ids = list( map(lambda x: int(x[0]), snaps)) adjacent_piece_props_snaps = list( map(lambda x: x[1:], snaps)) property_list = [ "x", "y", "r", # "g" ] results = [] with redis_connection.pipeline( transaction=True) as pipe: for adjacent_piece_id in adjacent_piece_ids: pc_puzzle_adjacent_piece_key = ( f"pc:{puzzle}:{adjacent_piece_id}") pipe.hmget( pc_puzzle_adjacent_piece_key, property_list, ) results = pipe.execute() for ( a_id, snapshot_adjacent, updated_adjacent, ) in zip( adjacent_piece_ids, adjacent_piece_props_snaps, results, ): updated_adjacent = list( map( lambda x: x if isinstance(x, str) else "", updated_adjacent, )) adjacent_offset = snapshot_adjacent.pop() if (snapshot_adjacent != updated_adjacent ) and adjacent_offset: (a_offset_x, a_offset_y) = map( int, adjacent_offset.split(",")) (a_snap_x, a_snap_y) = map( int, snapshot_adjacent[:2]) # Check if the x,y is within range of the adjacent piece that has moved piece_join_tolerance = current_app.config[ "PIECE_JOIN_TOLERANCE"] if (abs((a_snap_x + a_offset_x) - x) <= piece_join_tolerance and abs((a_snap_y + a_offset_y) - y) <= piece_join_tolerance): (a_moved_x, a_moved_y) = map( int, updated_adjacent[:2]) # Decrease pzq_current since it is moving an extra piece out of turn redis_connection.decr( pzq_current_key, amount=1) ( snapshot_msg, snapshot_karma_change, ) = attempt_piece_movement( ip, user, puzzle_data, piece, a_moved_x + a_offset_x, a_moved_y + a_offset_y, r, karma_change, karma, ) break except: pzq_current = int( redis_connection.get(pzq_current_key) or "0") if pzq_current == pzq_next - 1: # skip this piece move attempt redis_connection.incr(pzq_current_key, amount=1) current_app.logger.warning( "results123 other error {}".format(sys.exc_info()[0])) raise (msg, karma_change) = attempt_piece_movement( ip, user, puzzle_data, piece, x, y, r, karma_change or snapshot_karma_change, karma, ) if isinstance(snapshot_msg, str) and isinstance(msg, str): msg = snapshot_msg + msg break current_app.logger.debug(f"pzq_current is {pzq_current}") attempt_timestamp = time.time() attempt_count = attempt_count + 1 # TODO: The sleep time should be set based on an average time it # takes to process piece movements. time.sleep(0.02) # Decrease karma here to potentially block a player that # continually tries to move pieces when a puzzle is too active. if (len({"all", "too_active"}.intersection( current_app.config["PUZZLE_RULES"])) > 0) and karma > 0: karma = redis_connection.decr(karma_key) karma_change -= 1 current_app.logger.debug( f"Puzzle ({puzzle}) piece move attempts: {attempt_count}") if attempt_timestamp >= timeout: current_app.logger.warn( f"Puzzle {puzzle} is too active. Attempt piece move timed out after trying {attempt_count} times." ) err_msg = { "msg": "Piece movement timed out.", "type": "error", "reason": "Puzzle is too active", "timeout": piece_move_timeout, } return make_response( json.jsonify(err_msg), 503, ) # Check msg for error or if piece can't be moved if not isinstance(msg, str): if isinstance(msg, dict): return make_response(json.jsonify(msg), 400) else: current_app.logger.warning("Unknown error: {}".format(msg)) return make_response( json.jsonify({ "msg": msg, "type": "error", "timeout": 3 }), 500) # publish just the bit movement so it matches what this player did bitmsg = formatBitMovementString(user, x, y) sse.publish( bitmsg, type="move", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) if karma_change < 0: if karma + recent_points <= 0: return _blockplayer() # end = time.perf_counter() # current_app.logger.debug("PuzzlePiecesMovePublishView {}".format(end - start)) return make_response("", 204)
def get(self, puzzle_id, piece): ip = request.headers.get("X-Real-IP") user = current_app.secure_cookie.get("user") or user_id_from_ip( ip, validate_shared_user=False) if user is None: err_msg = { "msg": "Please reload the page.", "reason": "The player login was not found.", "type": "puzzlereload", "timeout": 300, } response = make_response(json.jsonify(err_msg), 400) expires = datetime.datetime.utcnow() - datetime.timedelta(days=365) current_app.secure_cookie.set("user", "", response, expires=expires) current_app.secure_cookie.set("shareduser", "", response, expires=expires) return response user = int(user) mark = request.args.get("mark") if not isinstance(mark, str) or len(mark) != 10: return make_response( json.jsonify({ "msg": "invalid args", "type": "invalid", }), 400, ) now = int(time.time()) # start = time.perf_counter() pzq_key = "pzq:{puzzle_id}".format(puzzle_id=puzzle_id) puzzle = redis_connection.hget(pzq_key, "puzzle") if not puzzle: current_app.logger.debug("no puzzle; fetch puzzle") r = requests.get( "http://{HOSTAPI}:{PORTAPI}/internal/puzzle/{puzzle_id}/details/" .format( HOSTAPI=current_app.config["HOSTAPI"], PORTAPI=current_app.config["PORTAPI"], puzzle_id=puzzle_id, ), ) if r.status_code >= 400: # 400 if puzzle does not exist err_msg = { "msg": "puzzle is not ready at this time. Please reload the page.", "type": "puzzleimmutable", } return make_response(json.jsonify(err_msg), r.status_code) try: result = r.json() except ValueError as err: err_msg = { "msg": "puzzle is not ready at this time. Please reload the page.", "type": "puzzleimmutable", } return make_response(json.jsonify(err_msg), 500) if result.get("status") not in (ACTIVE, BUGGY_UNLISTED): err_msg = { "msg": "puzzle is not ready at this time. Please reload the page.", "type": "puzzleimmutable", } return make_response(json.jsonify(err_msg), 400) puzzle = result["id"] puzzle = int(puzzle) pc_puzzle_piece_key = "pc:{puzzle}:{piece}".format(puzzle=puzzle, piece=piece) piece_properties = _int_piece_properties( redis_connection.hgetall(pc_puzzle_piece_key)) pcfixed = set(redis_connection.smembers(f"pcfixed:{puzzle}")) if piece_properties.get("y") is None: # 400 if puzzle does not exist or piece is not found # Only puzzles in ACTIVE state can be mutated err_msg = { "msg": "puzzle pieces can't be moved at this time. Please reload the page.", "type": "puzzleimmutable", } return make_response(json.jsonify(err_msg), 400) if piece in pcfixed: # immovable err_msg = { "msg": "piece can't be moved", "type": "immovable", "expires": now + 5, "timeout": 5, } return make_response(json.jsonify(err_msg), 400) # TODO: remove old entries in blockedplayers:{puzzle} blockedplayers_for_puzzle_key = "blockedplayers:{puzzle}".format( puzzle=puzzle) blockedplayers_expires = redis_connection.zscore( blockedplayers_for_puzzle_key, user) if blockedplayers_expires and blockedplayers_expires > now: err_msg = get_blockedplayers_err_msg(blockedplayers_expires, blockedplayers_expires - now) return make_response(json.jsonify(err_msg), 429) token = pack_token(nanoid.generate(size=8), puzzle, user, piece, piece_properties) redis_connection.publish( f"enforcer_token_request:{puzzle}", token, ) def move_bit_icon_to_piece(x, y): # Claim the piece by showing the bit icon next to it. msg = formatBitMovementString(user, x, y) sse.publish( msg, type="move", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) # Snapshot of adjacent pieces at time of token request snapshot_id = None adjacent_pieces_list = _get_adjacent_pieces_list(piece_properties) adjacent_property_list = ["x", "y", "r", "g", str(piece)] pzq_current_key = "pzq_current:{puzzle}".format(puzzle=puzzle) results = [] with redis_connection.pipeline(transaction=False) as pipe: for adjacent_piece in adjacent_pieces_list: pc_puzzle_adjacent_piece_key = "pc:{puzzle}:{adjacent_piece}".format( puzzle=puzzle, adjacent_piece=adjacent_piece) pipe.hmget(pc_puzzle_adjacent_piece_key, adjacent_property_list) pipe.get(pzq_current_key) results = pipe.execute() pzq_current = "0" if not isinstance(results[-1], list): pzq_current = results.pop() or pzq_current adjacent_properties = dict( zip( adjacent_pieces_list, map(lambda x: dict(zip(adjacent_property_list, x)), results), )) snapshot = [] for a_piece, a_props in adjacent_properties.items(): # skip any that are immovable if a_piece in pcfixed: continue # skip any that are in the same group if a_props.get("g") is not None and a_props.get( "g") == piece_properties.get("g"): continue # skip any that don't have offsets (adjacent edge piece) if not a_props.get(str(piece)): continue if a_props.get("g") is None: a_props["g"] = "" snapshot.append("_".join([ str(a_piece), a_props.get("x", ""), a_props.get("y", ""), a_props.get("r", ""), a_props.get(str(piece), ""), # a_props.get("g") ])) if len(snapshot): snapshot_id = nanoid.generate(size=8) snapshot_key = f"snap:{snapshot_id}" snapshot.insert(0, pzq_current) redis_connection.set(snapshot_key, ":".join(snapshot)) redis_connection.expire( snapshot_key, current_app.config["MAX_PAUSE_PIECES_TIMEOUT"] + (current_app.config["PIECE_MOVE_TIMEOUT"] + 2), ) validate_token = (len({"all", "valid_token"}.intersection( current_app.config["PUZZLE_RULES"])) > 0) TOKEN_LOCK_TIMEOUT = current_app.config["TOKEN_LOCK_TIMEOUT"] TOKEN_EXPIRE_TIMEOUT = current_app.config["TOKEN_EXPIRE_TIMEOUT"] if not validate_token: move_bit_icon_to_piece(piece_properties.get("x"), piece_properties.get("y")) response = { "token": token, "lock": now + TOKEN_LOCK_TIMEOUT, "expires": now + TOKEN_EXPIRE_TIMEOUT, } if snapshot_id: response["snap"] = snapshot_id return make_response(json.jsonify(response), 200) # Check if user already has a token for this puzzle. This would mean # that the user tried moving another piece before the locked piece # finished moving. existing_token = redis_connection.get(f"t:{mark}") if existing_token: # Temporary ban the player when clicking a piece and not # dropping it before clicking another piece. # Ban the user for a few seconds err_msg = increase_ban_time(user, TOKEN_LOCK_TIMEOUT) err_msg[ "reason"] = "Concurrent piece movements on this puzzle from the same player are not allowed." return make_response(json.jsonify(err_msg), 429) piece_token_queue_key = get_puzzle_piece_token_queue_key(puzzle, piece) with redis_connection.pipeline(transaction=False) as pipe: pipe.zrank(piece_token_queue_key, mark) pipe.expire(piece_token_queue_key, TOKEN_LOCK_TIMEOUT + 5) (queue_rank, _) = pipe.execute() if queue_rank is None: # Append this player to a queue for getting the next token. This # will prevent the player with the lock from continually locking the # same piece. with redis_connection.pipeline(transaction=False) as pipe: pipe.zadd(piece_token_queue_key, {mark: now}) pipe.zrank(piece_token_queue_key, mark) (_, queue_rank) = pipe.execute() # Check if token on piece is in a queue and if the player requesting it # is the player that is next. Show an error message if not. if queue_rank > 0: err_msg = { "msg": "Another player is waiting to move this piece", "type": "piecequeue", "reason": "Piece queue {}".format(queue_rank), "expires": now + TOKEN_LOCK_TIMEOUT, "timeout": TOKEN_LOCK_TIMEOUT, } return make_response(json.jsonify(err_msg), 409) # Check if token on piece is still owned by another user puzzle_piece_token_key = get_puzzle_piece_token_key(puzzle, piece) existing_token_and_mark = redis_connection.get(puzzle_piece_token_key) if existing_token_and_mark: (other_token, other_mark) = existing_token_and_mark.split(":") puzzle_and_piece_and_user = redis_connection.get(f"t:{other_mark}") # Check if there is a lock on this piece by other user if puzzle_and_piece_and_user: ( other_puzzle, other_piece, other_user, ) = puzzle_and_piece_and_user.split(":") other_puzzle = int(other_puzzle) other_piece = int(other_piece) other_user = int(other_user) if other_puzzle == puzzle and other_piece == piece: # Other user has a lock on this piece err_msg = { "msg": "Another player is moving this piece", "type": "piecelock", "reason": "Piece locked", } return make_response(json.jsonify(err_msg), 409) # This piece is up for grabs since it has been more then 5 seconds since # another player has grabbed it. with redis_connection.pipeline(transaction=False) as pipe: # Remove player from the piece token queue pipe.zrem(piece_token_queue_key, mark) pipe.set( puzzle_piece_token_key, f"{token}:{mark}", ex=TOKEN_EXPIRE_TIMEOUT, ) pipe.set( f"t:{mark}", f"{puzzle}:{piece}:{user}", ex=TOKEN_LOCK_TIMEOUT, ) pipe.execute() move_bit_icon_to_piece(piece_properties.get("x"), piece_properties.get("y")) response = { "token": token, "lock": now + TOKEN_LOCK_TIMEOUT, "expires": now + TOKEN_EXPIRE_TIMEOUT, } if snapshot_id: response["snap"] = snapshot_id # end = time.perf_counter() # current_app.logger.debug("PuzzlePieceTokenView {}".format(end - start)) return make_response(json.jsonify(response), 200)
def post(self): "Route is protected by basic auth in nginx" args = {} batch = [] user = int( current_app.secure_cookie.get("user") or user_id_from_ip(request.headers.get("X-Real-IP"))) cur = db.cursor() result = cur.execute( fetch_query_string("select-user-details-by-id.sql"), { "id": user }, ).fetchone() if not result: # Safe guard against when the user is not in the database. Usually # happens in development environments when switching and dropping # databases happens often. user = ANONYMOUS_USER_ID cur.close() if request.form: args.update(request.form.to_dict(flat=True)) labels = [ "unlisted", "hidden_preview", "link", "bg_color", "pieces" ] batch = list( map( lambda x: dict(zip(labels, x)), list(zip(*list(map(request.form.getlist, labels)))), )) for item in batch: try: pieces = int( item.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if (not current_app.config["MINIMUM_PIECE_COUNT"] <= pieces <= current_app.config["MAXIMUM_PIECE_COUNT"]): abort(400) bg_color = check_bg_color(item.get("bg_color", "#808080")[:50]) permission = PUBLIC if item.get("unlisted", "false") == "false" else PRIVATE description = "" link = url_fix(item.get("link", ""))[:1000] secret_message = escape(item.get("secret_message", ""))[:1000] features = set() if item.get("hidden_preview", "false") != "false": features.add("hidden-preview") puzzle_id = submit_puzzle( pieces, bg_color, user, permission, description, link, upload_file=None, secret_message=secret_message, features=features, ) return redirect("/chill/site/player-puzzle-list/", code=303)
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) args["features"] = set(request.form.getlist("features")) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if pieces < current_app.config["MINIMUM_PIECE_COUNT"]: abort(400) bg_color = check_bg_color(args.get("bg_color", "#808080")[:50]) # Check description instance_description = escape(args.get("instance_description", ""))[:1000] # Check secret_message secret_message = escape(args.get("secret_message", ""))[:1000] # Check puzzle_id source_puzzle_id = args.get("puzzle_id") if not source_puzzle_id: abort(400) # Check fork fork = int(args.get("fork", "0")) if fork not in (0, 1): abort(400) fork = bool(fork == 1) # Validate permission permission = int(args.get("permission", PUBLIC)) if permission not in (PUBLIC, PRIVATE): abort(400) if fork: # All copies of puzzles are unlisted permission = PRIVATE # Note that the permission value is updated to be unlisted later on if # the source puzzle is unlisted based on source puzzle data. user = int( current_app.secure_cookie.get("user") or user_id_from_ip(request.headers.get("X-Real-IP"))) cur = db.cursor() # The user should have # 2400 or more dots (points) # TODO: this could be configurable per site or for other reasons. # userHasEnoughPoints = cur.execute(fetch_query_string("select-minimum-points-for-user.sql"), {'user': user, 'points': 2400}).fetchall() # if not userHasEnoughPoints: # abort(400) # An available instance slot result = cur.execute( fetch_query_string( "select-available-user-puzzle-slot-for-player.sql"), { "player": user }, ).fetchone()[0] userHasAvailablePuzzleInstanceSlot = bool(result) if not userHasAvailablePuzzleInstanceSlot: cur.close() db.commit() abort(400) # Check if puzzle is valid to be a new puzzle instance if not fork: # Creating a new puzzle instance result = cur.execute( fetch_query_string( "select-valid-puzzle-for-new-puzzle-instance.sql"), { "puzzle_id": source_puzzle_id, "ACTIVE": ACTIVE, "IN_QUEUE": IN_QUEUE, "COMPLETED": COMPLETED, "FROZEN": FROZEN, "REBUILD": REBUILD, "IN_RENDER_QUEUE": IN_RENDER_QUEUE, "RENDERING": RENDERING, }, ).fetchall() if not result: # Puzzle does not exist or is not a valid puzzle to create instance from. cur.close() db.commit() abort(400) else: # Creating a copy of existing puzzle pieces (forking) result = cur.execute( fetch_query_string( "select-valid-puzzle-for-new-puzzle-instance-fork.sql"), { "puzzle_id": source_puzzle_id, "ACTIVE": ACTIVE, "IN_QUEUE": IN_QUEUE, "COMPLETED": COMPLETED, "FROZEN": FROZEN, }, ).fetchall() if not result: # Puzzle does not exist or is not a valid puzzle to create instance from. cur.close() db.commit() abort(400) (result, col_names) = rowify(result, cur.description) source_puzzle_data = result[0] result = cur.execute( fetch_query_string("select-puzzle-features-for-puzzle_id.sql"), { "puzzle_id": source_puzzle_id, "enabled": 1 }, ).fetchall() source_features = set() if result: (result, _) = rowify(result, cur.description) source_features = set(map(lambda x: x["slug"], result)) # Set the permission of new puzzle to be unlisted if source puzzle is # that way. The form should have this field as disabled if the source # puzzle is unlisted; which would mean it isn't sent as a parameter. if source_puzzle_data["permission"] == PRIVATE: permission = PRIVATE puzzle_id = generate_new_puzzle_id(source_puzzle_data["name"]) # Create puzzle dir if not fork: puzzle_dir = os.path.join( current_app.config.get("PUZZLE_RESOURCES"), puzzle_id) os.mkdir(puzzle_dir) if not fork: d = { "puzzle_id": puzzle_id, "pieces": pieces, "name": source_puzzle_data["name"], "link": source_puzzle_data["link"], "description": source_puzzle_data["description"] if not instance_description else instance_description, "bg_color": bg_color, "owner": user, "queue": QUEUE_NEW, "status": IN_RENDER_QUEUE, "permission": permission, } cur.execute( fetch_query_string("insert_puzzle.sql"), d, ) else: d = { "puzzle_id": puzzle_id, "pieces": source_puzzle_data["pieces"], "rows": source_puzzle_data["rows"], "cols": source_puzzle_data["cols"], "piece_width": source_puzzle_data["piece_width"], "mask_width": source_puzzle_data["mask_width"], "table_width": source_puzzle_data["table_width"], "table_height": source_puzzle_data["table_height"], "name": source_puzzle_data["name"], "link": source_puzzle_data["link"], "description": source_puzzle_data["description"] if not instance_description else instance_description, "bg_color": bg_color, "owner": user, "queue": QUEUE_NEW, "status": MAINTENANCE, "permission": permission, # All copies of puzzles are unlisted } cur.execute( fetch_query_string("insert_puzzle_instance_copy.sql"), d, ) db.commit() result = cur.execute( fetch_query_string("select_puzzle_id_by_puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall() if not result: cur.close() db.commit() current_app.logger.error( f"Failed to get puzzle id from select_puzzle_id_by_puzzle_id.sql using {puzzle_id}" ) abort(500) puzzle = result[0][0] classic_variant = cur.execute( fetch_query_string("select-puzzle-variant-id-for-slug.sql"), { "slug": CLASSIC }, ).fetchone()[0] cur.execute( fetch_query_string("insert-puzzle-instance.sql"), { "original": source_puzzle_data["id"], "instance": puzzle, "variant": classic_variant, }, ) cur.execute( fetch_query_string("fill-user-puzzle-slot.sql"), { "player": user, "puzzle": puzzle }, ) features = args.get("features") result = cur.execute( fetch_query_string("select-puzzle-features-enabled.sql"), { "enabled": 1 }).fetchall() if result: (puzzle_features, _) = rowify(result, cur.description) # Add puzzle features for puzzle_feature in puzzle_features: if puzzle_feature[ "slug"] == "hidden-preview" and "hidden-preview" in features.union( source_features): # If source puzzle had hidden-preview then this puzzle will # also. cur.execute( fetch_query_string( "add-puzzle-feature-to-puzzle-by-id--hidden-preview.sql" ), { "puzzle": puzzle, "puzzle_feature": puzzle_feature["id"] }, ) elif (puzzle_feature["slug"] == "secret-message" and "secret-message" in features): cur.execute( fetch_query_string( "add-puzzle-feature-to-puzzle-by-id--secret-message.sql" ), { "puzzle": puzzle, "puzzle_feature": puzzle_feature["id"], "message": secret_message, }, ) result = cur.execute( fetch_query_string( "select-all-and-preview_full-from-puzzle-by-puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall() if not result: cur.close() db.commit() current_app.logger.error( f"Failed to get result from select-all-and-preview_full-from-puzzle-by-puzzle_id.sql using {puzzle_id}" ) abort(500) (result, col_names) = rowify(result, cur.description) puzzle_data = result[0] db.commit() cur.close() if not fork: job = current_app.createqueue.enqueue( "api.jobs.pieceRenderer.render", [puzzle_data], result_ttl=0, job_timeout="24h", ) else: # Copy existing puzzle job = current_app.cleanupqueue.enqueue( "api.jobs.piece_forker.fork_puzzle_pieces", source_puzzle_data, puzzle_data, result_ttl=0, ) return redirect("/chill/site/front/{0}/".format(puzzle_id), code=303)
def patch(self, puzzle_id): "Pong. Determine the latency for this player." response = {"message": "", "name": "", "data": {"latency": 0}} args = {} xhr_data = request.get_json() if xhr_data: args.update(xhr_data) if request.form: args.update(request.form.to_dict(flat=True)) token = args.get("token") if token is None: response["message"] = "No token" response["name"] = "error" return make_response(json.jsonify(response), 400) user = current_app.secure_cookie.get(u"user") or user_id_from_ip( request.headers.get("X-Real-IP"), skip_generate=True, validate_shared_user=False, ) if user is None: response["message"] = "Player not currently logged in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) cur = db.cursor() # Validate the puzzle_id result = cur.execute( fetch_query_string("select-id-status-from-puzzle-by-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: response["message"] = "Puzzle not available" response["name"] = "invalid" cur.close() return make_response(json.jsonify(response), 400) else: (result, col_names) = rowify(result, cur.description) puzzle = result[0].get("id") status = result[0].get("status") if status not in ( ACTIVE, IN_QUEUE, COMPLETED, FROZEN, BUGGY_UNLISTED, NEEDS_MODERATION, REBUILD, IN_RENDER_QUEUE, RENDERING, RENDERING_FAILED, MAINTENANCE, ): response["message"] = "Puzzle no longer valid" response["name"] = "invalid" cur.close() sse.publish( "Puzzle no longer valid", type="invalid", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) return make_response(json.jsonify(response), 200) cur.close() # Determine latency for the player and record timestamp in sorted set. pingtoken_key = get_pingtoken_key(puzzle, user, token) ping_start = redis_connection.get(pingtoken_key) redis_connection.delete(pingtoken_key) ping_end = int(time.time() * 1000) if not ping_start: response["message"] = "Ignoring error when determining latency." response["name"] = "ignored" return make_response(json.jsonify(response), 200) ping_start = int(ping_start) ping_key = get_ping_key(puzzle) redis_connection.zadd(ping_key, {user: ping_end}) redis_connection.expire(ping_key, PING_EXPIRE) latency = ping_end - ping_start # Record the latency for the player redis_connection.lpush( "latency", "{user}:{timestamp}:{latency}".format( user=user, timestamp=ping_end, latency=latency ), ) # Keep only the last 1000 entries to latency redis_connection.ltrim("latency", 0, 999) response["message"] = "Latency" response["data"]["latency"] = latency response["name"] = "success" response = make_response(json.jsonify(response), 200) return response
def post(self): """If the bit icon is available; claim it for the user.""" data = {"message": "", "name": "error"} icon = request.args.get("icon") if not icon: data["message"] = "No icon param passed" data["name"] = "error" return make_response(json.jsonify(data), 400) # Prevent creating a new user if no support for cookies. Player should # have 'ot' already set by viewing the page. uses_cookies = current_app.secure_cookie.get(u"ot") if not uses_cookies: data["message"] = "No ot cookie present" data["name"] = "error" return make_response(json.jsonify(data), 400) cur = db.cursor() # Check if bit icon is available result = cur.execute( fetch_query_string("select_available_bit_icon.sql"), {"icon": icon} ).fetchone() if not result: cur.close() db.commit() data["message"] = "That bit icon is no longer available." data["name"] = "error" return make_response(json.jsonify(data), 400) user = current_app.secure_cookie.get(u"user") if not user: user = user_id_from_ip(request.headers.get("X-Real-IP")) if user == None: data["message"] = "Not logged in." data["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(data), 400) user = int(user) else: user = int(user) data["message"] = "Bit icon claimed by using {} of your dots.".format( current_app.config["POINT_COST_FOR_CHANGING_BIT"] ) data["name"] = "success" response = make_response(json.jsonify(data), 200) # Unclaim any bit icon that the player already has cur.execute(fetch_query_string("unclaim_bit_icon.sql"), {"user": user}) # Claim the bit icon cur.execute( fetch_query_string("update_bit_icon_user.sql"), {"user": user, "icon": icon} ) cur.execute( fetch_query_string("decrease-user-points.sql"), { "points": current_app.config["POINT_COST_FOR_CHANGING_BIT"], "user": user, }, ) cur.close() db.commit() purge_route_from_nginx_cache( "/chill/site/internal/player-bit/{}/".format(user), current_app.config.get("PURGEURLLIST"), ) return response
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) # Check pieces arg try: pieces = int(args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if pieces < current_app.config["MINIMUM_PIECE_COUNT"]: abort(400) bg_color = check_bg_color(args.get("bg_color", "#808080")[:50]) # Check description instance_description = args.get("instance_description", "") # Check puzzle_id source_puzzle_id = args.get("puzzle_id") if not source_puzzle_id: abort(400) # Check fork fork = int(args.get("fork", "0")) if fork not in (0, 1): abort(400) fork = bool(fork == 1) # Check permission permission = int(args.get("permission", PUBLIC)) if permission not in (PUBLIC, PRIVATE): abort(400) if fork: # All copies of puzzles are unlisted permission = PRIVATE user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP")) ) cur = db.cursor() # The user should have # 2400 or more dots (points) # TODO: this could be configurable per site or for other reasons. # userHasEnoughPoints = cur.execute(fetch_query_string("select-minimum-points-for-user.sql"), {'user': user, 'points': 2400}).fetchall() # if not userHasEnoughPoints: # abort(400) # An available instance slot result = cur.execute( fetch_query_string("select-available-user-puzzle-slot-for-player.sql"), {"player": user}, ).fetchone()[0] userHasAvailablePuzzleInstanceSlot = bool(result) if not userHasAvailablePuzzleInstanceSlot: cur.close() db.commit() abort(400) # Check if puzzle is valid to be a new puzzle instance if not fork: # Creating a new puzzle instance result = cur.execute( fetch_query_string("select-valid-puzzle-for-new-puzzle-instance.sql"), { "puzzle_id": source_puzzle_id, "ACTIVE": ACTIVE, "IN_QUEUE": IN_QUEUE, "COMPLETED": COMPLETED, "FROZEN": FROZEN, "REBUILD": REBUILD, "IN_RENDER_QUEUE": IN_RENDER_QUEUE, "RENDERING": RENDERING, }, ).fetchall() if not result: # Puzzle does not exist or is not a valid puzzle to create instance from. cur.close() db.commit() abort(400) else: # Creating a copy of existing puzzle pieces (forking) result = cur.execute( fetch_query_string( "select-valid-puzzle-for-new-puzzle-instance-fork.sql" ), { "puzzle_id": source_puzzle_id, "ACTIVE": ACTIVE, "IN_QUEUE": IN_QUEUE, "COMPLETED": COMPLETED, "FROZEN": FROZEN, }, ).fetchall() if not result: # Puzzle does not exist or is not a valid puzzle to create instance from. cur.close() db.commit() abort(400) (result, col_names) = rowify(result, cur.description) source_puzzle_data = result[0] puzzle_id = generate_new_puzzle_id(source_puzzle_data["name"]) # Create puzzle dir if not fork: puzzle_dir = os.path.join( current_app.config.get("PUZZLE_RESOURCES"), puzzle_id ) os.mkdir(puzzle_dir) if not fork: d = { "puzzle_id": puzzle_id, "pieces": pieces, "name": source_puzzle_data["name"], "link": source_puzzle_data["link"], "description": source_puzzle_data["description"] if not instance_description else instance_description, "bg_color": bg_color, "owner": user, "queue": QUEUE_NEW, "status": IN_RENDER_QUEUE, "permission": permission, } cur.execute( fetch_query_string("insert_puzzle.sql"), d, ) else: d = { "puzzle_id": puzzle_id, "pieces": source_puzzle_data["pieces"], "rows": source_puzzle_data["rows"], "cols": source_puzzle_data["cols"], "piece_width": source_puzzle_data["piece_width"], "mask_width": source_puzzle_data["mask_width"], "table_width": source_puzzle_data["table_width"], "table_height": source_puzzle_data["table_height"], "name": source_puzzle_data["name"], "link": source_puzzle_data["link"], "description": source_puzzle_data["description"] if not instance_description else instance_description, "bg_color": bg_color, "owner": user, "queue": QUEUE_NEW, "status": MAINTENANCE, "permission": permission, # All copies of puzzles are unlisted } cur.execute( fetch_query_string("insert_puzzle_instance_copy.sql"), d, ) db.commit() result = cur.execute( fetch_query_string("select-all-from-puzzle-by-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: cur.close() db.commit() abort(500) (result, col_names) = rowify(result, cur.description) puzzle_data = result[0] puzzle = puzzle_data["id"] classic_variant = cur.execute( fetch_query_string("select-puzzle-variant-id-for-slug.sql"), {"slug": CLASSIC}, ).fetchone()[0] cur.execute( fetch_query_string("insert-puzzle-instance.sql"), { "original": source_puzzle_data["id"], "instance": puzzle, "variant": classic_variant, }, ) cur.execute( fetch_query_string("fill-user-puzzle-slot.sql"), {"player": user, "puzzle": puzzle}, ) db.commit() cur.close() if not fork: job = current_app.createqueue.enqueue_call( func="api.jobs.pieceRenderer.render", args=([puzzle_data]), result_ttl=0, timeout="24h", ) else: # Copy existing puzzle job = current_app.cleanupqueue.enqueue_call( func="api.jobs.piece_forker.fork_puzzle_pieces", args=([source_puzzle_data, puzzle_data]), result_ttl=0, ) return redirect("/chill/site/front/{0}/".format(puzzle_id), code=303)
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) puzzle_id = args.get("puzzle_id") if not puzzle_id: abort(400) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if pieces < current_app.config["MINIMUM_PIECE_COUNT"]: abort(400) user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP"))) cur = db.cursor() result = cur.execute( fetch_query_string( "select_puzzle_for_puzzle_id_and_status_and_not_recent.sql"), { "puzzle_id": puzzle_id, "status": COMPLETED }, ).fetchall() if not result: # Puzzle does not exist or is not completed status. # Reload the page as the status may have been changed. cur.close() return redirect( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id)) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] puzzle = puzzleData["id"] userCanRebuildPuzzle = cur.execute( fetch_query_string("select-user-rebuild-puzzle-prereq.sql"), { "user": user, "puzzle": puzzle, "pieces": pieces }, ).fetchall() if not userCanRebuildPuzzle: cur.close() abort(400) original_puzzle_id = puzzleData["original_puzzle_id"] # Get the adjusted piece count depending on the size of the original and # the minimum piece size. original_puzzle_dir = os.path.join( current_app.config["PUZZLE_RESOURCES"], original_puzzle_id) # TODO: get path of original.jpg via the PuzzleFile query # TODO: use requests.get to get original.jpg and run in another thread imagefile = os.path.join(original_puzzle_dir, "original.jpg") im = Image.open(imagefile) (width, height) = im.size im.close() max_pieces_that_will_fit = int((old_div(width, MIN_PIECE_SIZE)) * (old_div(height, MIN_PIECE_SIZE))) # The user points for rebuilding the puzzle is decreased by the piece # count for the puzzle. Use at least minimum piece count (20) points for # smaller puzzles. Players that own a puzzle instance do not decrease # any points (dots) if the puzzle is complete. point_cost = max( current_app.config["MINIMUM_PIECE_COUNT"], min( max_pieces_that_will_fit, pieces, current_app.config["MAX_POINT_COST_FOR_REBUILDING"], ), ) if not (puzzleData["owner"] == user and puzzleData["puzzle_id"] == puzzleData["original_puzzle_id"]): cur.execute( fetch_query_string("decrease-user-points.sql"), { "user": user, "points": point_cost }, ) # Update puzzle status to be REBUILD and change the piece count cur.execute( fetch_query_string("update_status_puzzle_for_puzzle_id.sql"), { "puzzle_id": puzzle_id, "status": REBUILD, "pieces": pieces, "queue": QUEUE_REBUILD, }, ) puzzleData["status"] = REBUILD puzzleData["pieces"] = pieces db.commit() # Delete any piece data from redis since it is no longer needed. (all_pieces, col_names) = rowify( cur.execute( fetch_query_string("select_all_piece_ids_for_puzzle.sql"), { "puzzle": puzzle }, ).fetchall(), cur.description, ) cur.close() deletePieceDataFromRedis(redis_connection, puzzle, all_pieces) job = current_app.createqueue.enqueue_call( func="api.jobs.pieceRenderer.render", args=([puzzleData]), result_ttl=0, timeout="24h", ) job = current_app.cleanupqueue.enqueue_call( func="api.jobs.timeline_archive.archive_and_clear", kwargs=({ "puzzle": puzzle }), result_ttl=0, timeout="24h", ) purge_route_from_nginx_cache( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id), current_app.config.get("PURGEURLLIST"), ) return redirect( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id))
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) args["features"] = set(request.form.getlist("features")) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if (not current_app.config["MINIMUM_PIECE_COUNT"] <= pieces <= current_app.config["MAXIMUM_PIECE_COUNT"]): abort(400) bg_color = check_bg_color(args.get("bg_color", "#808080")[:50]) user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP"))) permission = int(args.get("permission", PUBLIC)) if permission not in (PUBLIC, PRIVATE): permission = PUBLIC description = escape(args.get("description", "").strip())[:1000] # Check secret_message secret_message = escape(args.get("secret_message", ""))[:1000] # Check link and validate link = url_fix(args.get("link", "").strip())[:100] if not link and not description: abort(400) puzzle_id = uuid.uuid1().hex features = set(args.get("features", [])) cur = db.cursor() d = { "puzzle_id": puzzle_id, "pieces": pieces, "link": link, "description": description, "bg_color": bg_color, "owner": user, "status": SUGGESTED, "permission": permission, } cur.execute( """insert into Puzzle ( puzzle_id, pieces, link, description, bg_color, owner, status, permission) values (:puzzle_id, :pieces, :link, :description, :bg_color, :owner, :status, :permission); """, d, ) db.commit() puzzle = rowify( cur.execute( fetch_query_string("select_puzzle_id_by_puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall(), cur.description, )[0][0] puzzle = puzzle["puzzle"] result = cur.execute( fetch_query_string("select-puzzle-features-enabled.sql"), { "enabled": 1 }).fetchall() if result: (puzzle_features, _) = rowify(result, cur.description) # Add puzzle features for puzzle_feature in puzzle_features: if (puzzle_feature["slug"] == "hidden-preview" and "hidden-preview" in features): cur.execute( fetch_query_string( "add-puzzle-feature-to-puzzle-by-id--hidden-preview.sql" ), { "puzzle": puzzle, "puzzle_feature": puzzle_feature["id"] }, ) elif (puzzle_feature["slug"] == "secret-message" and "secret-message" in features): cur.execute( fetch_query_string( "add-puzzle-feature-to-puzzle-by-id--secret-message.sql" ), { "puzzle": puzzle, "puzzle_feature": puzzle_feature["id"], "message": secret_message, }, ) db.commit() cur.close() # Send a notification email (silent fail if not configured) message = """ http://{DOMAIN_NAME}/chill/site/admin/puzzle/suggested/{puzzle_id}/ pieces: {pieces} bg_color: {bg_color} owner: {owner} link: {link} description: {description} """.format(DOMAIN_NAME=current_app.config.get("DOMAIN_NAME"), **d) current_app.logger.debug(message) if not current_app.config.get("DEBUG", True): try: send_message( current_app.config.get("EMAIL_MODERATOR"), "Suggested Image", message, current_app.config, ) except Exception as err: current_app.logger.warning( "Failed to send notification message for suggested image. email: {email}\n {message}\n error: {err}" .format( err=err, email=current_app.config.get("EMAIL_MODERATOR"), message=message, )) pass # Redirect to a thank you page (not revealing the puzzle_id) return redirect("/chill/site/suggested-puzzle-thank-you/", code=303)
def patch(self, puzzle_id): ip = request.headers.get("X-Real-IP") user = int(current_app.secure_cookie.get("user") or user_id_from_ip(ip)) # validate the args and headers args = {} xhr_data = request.get_json() if xhr_data: args.update(xhr_data) if request.form: args.update(request.form.to_dict(flat=True)) # Verify args action = args.get("action") if action not in INSTANCE_ACTIONS: abort(400) cur = db.cursor() # validate the puzzle_id result = cur.execute( fetch_query_string("select-puzzle-details-for-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: # 400 if puzzle does not exist err_msg = { "msg": "No puzzle found", } cur.close() return make_response(json.jsonify(err_msg), 400) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] if puzzleData["owner"] != user or puzzleData["is_original"]: cur.close() abort(400) if puzzleData["status"] not in ( FROZEN, ACTIVE, COMPLETED, BUGGY_UNLISTED, RENDERING_FAILED, REBUILD, IN_RENDER_QUEUE, MAINTENANCE, ): cur.close() abort(400) if action in ("freeze", "unfreeze") and puzzleData["status"] not in ( FROZEN, BUGGY_UNLISTED, ACTIVE, ): cur.close() abort(400) response = {} if action == "delete": ( delete_penalty, can_delete, delete_disabled_message, ) = self.get_delete_prereq(puzzleData) if not can_delete: response = {"msg": delete_disabled_message} cur.close() return make_response(json.jsonify(response), 400) if delete_penalty > 0: cur.execute( fetch_query_string("decrease-user-points.sql"), {"user": user, "points": delete_penalty}, ) delete_puzzle_resources(puzzle_id) cur.execute( fetch_query_string("delete_puzzle_file_for_puzzle.sql"), {"puzzle": puzzleData["id"]}, ) cur.execute( fetch_query_string("delete_piece_for_puzzle.sql"), {"puzzle": puzzleData["id"]}, ) msg = delete_puzzle_timeline(puzzle_id) if msg.get("status_code") >= 400: current_app.logger.error(msg.get("msg")) current_app.logger.error( f"Failed delete of puzzle timeline for puzzle_id {puzzle_id}" ) cur.execute( fetch_query_string("update_puzzle_status_for_puzzle.sql"), {"status": DELETED_REQUEST, "puzzle": puzzleData["id"]}, ) cur.execute( fetch_query_string("empty-user-puzzle-slot.sql"), {"player": user, "puzzle": puzzleData["id"]}, ) db.commit() sse.publish( "status:{}".format(DELETED_REQUEST), channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) response = { "status": DELETED_REQUEST, } elif action == "freeze": cur.execute( fetch_query_string("update_puzzle_status_for_puzzle.sql"), {"status": FROZEN, "puzzle": puzzleData["id"]}, ) db.commit() sse.publish( "status:{}".format(FROZEN), channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) response = { "status": FROZEN, } elif action == "unfreeze": # TODO: set status to COMPLETE if puzzle has been completed instead of ACTIVE cur.execute( fetch_query_string("update_puzzle_status_for_puzzle.sql"), {"status": ACTIVE, "puzzle": puzzleData["id"]}, ) db.commit() sse.publish( "status:{}".format(ACTIVE), channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) response = { "status": ACTIVE, } elif action == "reset": if not ( puzzleData.get("permission") == PRIVATE and not puzzleData.get("is_original") and puzzleData.get("status") in (FROZEN, BUGGY_UNLISTED, ACTIVE, COMPLETED) ): response = {"msg": "Only unlisted puzzle instances can be reset"} cur.close() return make_response(json.jsonify(response), 400) if puzzleData.get("status") not in ( ACTIVE, COMPLETED, FROZEN, BUGGY_UNLISTED, ): response = { "msg": "Puzzle is not in acceptable state in order to be reset" } cur.close() return make_response(json.jsonify(response), 400) if puzzleData.get("status") != ACTIVE: # Only update the response status if puzzle status is changing. # This way any response will trigger the "Reload" button since # the active puzzle status will have an empty response status. response = {"status": ACTIVE} job = current_app.cleanupqueue.enqueue( "api.jobs.piece_reset.reset_puzzle_pieces_and_handle_errors", puzzleData.get("id"), result_ttl=0, ) cur.close() purge_route_from_nginx_cache( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id), current_app.config.get("PURGEURLLIST"), ) return make_response(json.jsonify(response), 202)
def post(self): "" response = {"message": "", "name": "error"} user = current_app.secure_cookie.get(u"user") if user: response[ "message"] = "User currently logged in. No need to reset the login by e-mail." response["name"] = "error" return make_response(json.jsonify(response), 400) user = user_id_from_ip(request.headers.get("X-Real-IP")) if user == None: response["message"] = "Shared user not currently logged in." response["name"] = "error" return make_response(json.jsonify(response), 400) args = {} if request.form: args.update(request.form.to_dict(flat=True)) email = args.get("email", "").strip().lower() if len(email) > EMAIL_MAXLENGTH: response["message"] = "E-mail is too long." response["name"] = "error" return make_response(json.jsonify(response), 400) cur = db.cursor() # Get user by their verified email address result = cur.execute( fetch_query_string("get-user-by-verified-email-address.sql"), { "email": email }, ).fetchone() if not result or result[0] == 0: response[ "message"] = "Sorry, that e-mail address has not been verified." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) else: user = result[0] result = cur.execute( fetch_query_string("select-player-details-for-player-id.sql"), { "player_id": user }, ).fetchall() if not result: # This shouldn't happen if user-has-player-account.sql response["message"] = "No player account." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) (result, col_names) = rowify(result, cur.description) existing_player_data = result[0] if existing_player_data["has_active_reset_login_token"]: response[ "message"] = "Please check your e-mail for a reset login link. The reset login link that was sent earlier has not expired." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) # Send a link to reset the login (silent fail if not configured) token = uuid.uuid4().hex message = """ The link below is only valid for a short time. Use it to reset your login link for Puzzle Massive. http://{DOMAIN_NAME}/chill/site/reset-login/{token}/ After visiting that web page and clicking the reset login button; a new login link will be created. Any older ones will no longer be valid. You can ignore this message if you didn't initiate the request. """.format(token=token, DOMAIN_NAME=current_app.config.get("DOMAIN_NAME")) current_app.logger.debug(message) email_sent = False if not current_app.config.get("DEBUG", True): try: send_message(email, "Puzzle Massive - reset login", message, current_app.config) email_sent = True except Exception as err: current_app.logger.warning( "Failed to send reset login message. email: {email}\n {message}\n error: {err}" .format(err=err, email=email, message=message)) email_sent = False cur.execute( fetch_query_string("update-player-account-login-token.sql"), { "player_id": user, "reset_login_token": token, "expire_token_timeout": "+1 day", }, ) if current_app.config.get("DEBUG", True): response[ "message"] = "A reset login link has been sent to your e-mail. DEBUG is True, so did not really send the email. Check the logs for the login link." response["name"] = "success" elif not email_sent: response[ "message"] = "Failed to send reset login link to your e-mail.".format( email) response["name"] = "error" else: response[ "message"] = "A reset login link has been sent to your e-mail.".format( email) response["name"] = "success" db.commit() cur.close() return make_response(json.jsonify(response), 202)
def post(self): "" response = {"message": "", "name": "error"} user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP"))) if user == None: response["message"] = "User not signed in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) args = {} if request.form: args.update(request.form.to_dict(flat=True)) display_name = args.get("name", "").strip() name = normalize_name_from_display_name(display_name) # name is always converted to lowercase and display_name preserves # original case. display_name = args.get("name", "").strip() name = normalize_name_from_display_name(display_name) if len(display_name) > USER_NAME_MAXLENGTH: response["message"] = "Submitted name is too long." response["name"] = "error" return make_response(json.jsonify(response), 400) cur = db.cursor() result = cur.execute( fetch_query_string("select-minimum-points-for-user.sql"), { "user": user, "points": current_app.config["POINT_COST_FOR_CHANGING_NAME"], }, ).fetchone() if not result: response["message"] = "Not enough points to change name." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) else: if name == "": cur.execute( fetch_query_string( "remove-user-name-on-name-register-for-player.sql"), { "player_id": user, }, ) cur.execute( fetch_query_string("decrease-user-points.sql"), { "points": current_app.config["POINT_COST_FOR_CHANGING_NAME"], "user": user, }, ) response["message"] = "Removed name." response["name"] = "success" else: result = cur.execute( fetch_query_string( "check-status-of-name-on-name-register.sql"), { "name": name }, ).fetchall() if result: (result, col_names) = rowify(result, cur.description) name_status = result[0] if name_status["rejected"] == 1: response[ "message"] = "Submitted name has been rejected before." response["name"] = "rejected" elif name_status["claimed"] == 0 or name_status[ "user"] == user: # The name is available and can be claimed. If owned by the # user the casing of the letters can be modified. cur.execute( fetch_query_string( "remove-user-name-on-name-register-for-player.sql" ), { "player_id": user, }, ) # Also updates the display_name if casing has changed cur.execute( fetch_query_string( "claim-user-name-on-name-register-for-player.sql" ), { "player_id": user, "name": name, "display_name": display_name, "time": "+5 minutes", }, ) cur.execute( fetch_query_string("decrease-user-points.sql"), { "points": current_app. config["POINT_COST_FOR_CHANGING_NAME"], "user": user, }, ) response[ "message"] = "Submitted name ({}) reclaimed.".format( display_name) response["name"] = "success" else: response[ "message"] = "Submitted name ({}) is currently used by another player. Please try a different name.".format( display_name) response["name"] = "rejected" else: # name is new cur.execute( fetch_query_string( "remove-user-name-on-name-register-for-player.sql" ), { "player_id": user, }, ) cur.execute( fetch_query_string( "add-user-name-on-name-register-for-player-to-be-reviewed.sql" ), { "player_id": user, "name": name, "display_name": display_name, "time": "+5 minutes", }, ) cur.execute( fetch_query_string("decrease-user-points.sql"), { "points": current_app.config["POINT_COST_FOR_CHANGING_NAME"], "user": user, }, ) response[ "message"] = 'Thank you for submitting a new name. "{}" will be shown next to your bit icon in about 10 minutes.'.format( display_name) response["name"] = "success" db.commit() cur.close() purge_route_from_nginx_cache( "/chill/site/internal/player-bit/{}/".format(user), current_app.config.get("PURGEURLLIST"), ) return make_response(json.jsonify(response), 202)
def patch(self, puzzle_id): "Pong. Determine the latency for this player." response = {"message": "", "name": "", "data": {"latency": 0}} args = {} xhr_data = request.get_json() if xhr_data: args.update(xhr_data) if request.form: args.update(request.form.to_dict(flat=True)) token = args.get("token") if token == None: response["message"] = "No token" response["name"] = "error" return make_response(json.jsonify(response), 400) user = current_app.secure_cookie.get(u"user") or user_id_from_ip( request.headers.get("X-Real-IP"), skip_generate=True) if user == None: response["message"] = "Player not currently logged in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) cur = db.cursor() # Validate the puzzle_id result = cur.execute( fetch_query_string("select_viewable_puzzle_id.sql"), { "puzzle_id": puzzle_id }, ).fetchall() if not result: response["message"] = "Invalid puzzle id." response["name"] = "error" cur.close() db.commit() return make_response(json.jsonify(response), 400) else: (result, col_names) = rowify(result, cur.description) puzzle = result[0].get("puzzle") status = result[0].get("status") if status != ACTIVE: response["message"] = "Puzzle not active" response["name"] = "invalid" cur.close() db.commit() return make_response(json.jsonify(response), 200) # Determine latency for the player and record timestamp in sorted set. pingtoken_key = get_pingtoken_key(puzzle, user, token) ping_start = redis_connection.get(pingtoken_key) redis_connection.delete(pingtoken_key) ping_end = int(time.time() * 1000) if not ping_start: response["message"] = "Ignoring error when determining latency." response["name"] = "ignored" return make_response(json.jsonify(response), 200) ping_start = int(ping_start) ping_key = get_ping_key(puzzle) redis_connection.zadd(ping_key, {user: ping_end}) redis_connection.expire(ping_key, PING_EXPIRE) latency = ping_end - ping_start # Record the latency for the player redis_connection.lpush( "latency", "{user}:{timestamp}:{latency}".format(user=user, timestamp=ping_end, latency=latency), ) # Keep only the last 1000 entries to latency redis_connection.ltrim("latency", 0, 999) response["message"] = "Latency" response["data"]["latency"] = latency response["name"] = "success" response = make_response(json.jsonify(response), 200) return response
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) args["features"] = set(request.form.getlist("features")) # Only allow valid contributor if args.get("contributor", None) != current_app.config.get("NEW_PUZZLE_CONTRIB"): abort(403) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if (not current_app.config["MINIMUM_PIECE_COUNT"] <= pieces <= current_app.config["MAXIMUM_PIECE_COUNT"]): abort(400) bg_color = check_bg_color(args.get("bg_color", "#808080")[:50]) user = int( current_app.secure_cookie.get("user") or user_id_from_ip(request.headers.get("X-Real-IP"))) cur = db.cursor() result = cur.execute( fetch_query_string("select-user-details-by-id.sql"), { "id": user }, ).fetchone() if not result: # Safe guard against when the user is not in the database. Usually # happens in development environments when switching and dropping # databases happens often. user = ANONYMOUS_USER_ID cur.close() permission = int(args.get("permission", PUBLIC)) if permission not in (PUBLIC, PRIVATE): permission = PUBLIC description = escape(args.get("description", ""))[:1000] # Check secret_message secret_message = escape(args.get("secret_message", ""))[:1000] # Check link and validate link = url_fix(args.get("link", ""))[:1000] upload_file = request.files.get("upload_file", None) features = set(args.get("features", [])) puzzle_id = submit_puzzle( pieces, bg_color, user, permission, description, link, upload_file, secret_message=secret_message, features=features, ) # TODO AUTO_APPROVE_PUZZLES only works for non Unsplash photos at the moment. if current_app.config["AUTO_APPROVE_PUZZLES"] and not re.search( unsplash_url_regex, link): cur = db.cursor() puzzles = rowify( cur.execute( fetch_query_string("select-puzzles-in-render-queue.sql"), { "IN_RENDER_QUEUE": IN_RENDER_QUEUE, "REBUILD": REBUILD }, ).fetchall(), cur.description, )[0] cur.close() print("found {0} puzzles to render or rebuild".format( len(puzzles))) # push each puzzle to artist job queue for puzzle in puzzles: job = current_app.createqueue.enqueue( "api.jobs.pieceRenderer.render", [puzzle], result_ttl=0, job_timeout="24h", ) return redirect("/chill/site/front/{0}/".format(puzzle_id), code=303)
def post(self, puzzle_id): "Ping and record the time in milliseconds for this player." now_ms = int(time.time() * 1000) response = {"message": "", "name": ""} user = current_app.secure_cookie.get(u"user") or user_id_from_ip( request.headers.get("X-Real-IP"), skip_generate=True, validate_shared_user=False, ) if user is None: response["message"] = "Player not currently logged in." response["name"] = "error" return make_response(json.jsonify(response), 400) user = int(user) cur = db.cursor() # Validate the puzzle_id result = cur.execute( fetch_query_string("select-id-status-from-puzzle-by-puzzle_id.sql"), {"puzzle_id": puzzle_id}, ).fetchall() if not result: response["message"] = "Puzzle not available" response["name"] = "invalid" cur.close() return make_response(json.jsonify(response), 400) else: (result, col_names) = rowify(result, cur.description) puzzle = result[0].get("id") status = result[0].get("status") if status not in ( ACTIVE, IN_QUEUE, COMPLETED, FROZEN, BUGGY_UNLISTED, NEEDS_MODERATION, REBUILD, IN_RENDER_QUEUE, RENDERING, RENDERING_FAILED, MAINTENANCE, ): response["message"] = "Puzzle no longer valid" response["name"] = "invalid" cur.close() sse.publish( "Puzzle no longer valid", type="invalid", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) return make_response(json.jsonify(response), 200) cur.close() # publish to the puzzle channel the ping with the user id. This will # allow that player to determine their latency. token = uuid.uuid4().hex[:4] pingtoken_key = get_pingtoken_key(puzzle, user, token) redis_connection.setex(pingtoken_key, 60, now_ms) current_app.logger.debug("publish ping {puzzle_id}".format(puzzle_id=puzzle_id)) sse.publish( "{user}:{token}".format(user=user, token=token), type="ping", channel="puzzle:{puzzle_id}".format(puzzle_id=puzzle_id), ) response["message"] = "ping accepted" response["name"] = "accepted" response = make_response(json.jsonify(response), 202) return response
def post(self): args = {} if request.form: args.update(request.form.to_dict(flat=True)) puzzle_id = args.get("puzzle_id") if not puzzle_id: abort(400) # Check pieces arg try: pieces = int( args.get("pieces", current_app.config["MINIMUM_PIECE_COUNT"])) except ValueError as err: abort(400) if pieces < current_app.config["MINIMUM_PIECE_COUNT"]: abort(400) user = int( current_app.secure_cookie.get(u"user") or user_id_from_ip(request.headers.get("X-Real-IP"))) cur = db.cursor() result = cur.execute( fetch_query_string( "select_puzzle_for_puzzle_id_and_status_and_not_recent.sql"), { "puzzle_id": puzzle_id, "status": COMPLETED }, ).fetchall() if not result: # Puzzle does not exist or is not completed status. # Reload the page as the status may have been changed. cur.close() return redirect( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id)) (result, col_names) = rowify(result, cur.description) puzzleData = result[0] puzzle = puzzleData["id"] userCanRebuildPuzzle = cur.execute( fetch_query_string("select-user-rebuild-puzzle-prereq.sql"), { "user": user, "puzzle": puzzle, "pieces": pieces }, ).fetchall() if not userCanRebuildPuzzle: cur.close() abort(400) if (puzzleData["permission"] == PRIVATE and puzzleData["original_puzzle_id"] == puzzleData["puzzle_id"]): current_app.logger.warning( "Original puzzles that are private can not be rebuilt") cur.close() abort(400) # The user points for rebuilding the puzzle is decreased by the piece # count for the puzzle. Use at least minimum piece count (20) points for # smaller puzzles. Players that own a puzzle instance do not decrease # any points (dots) if the puzzle is complete. point_cost = max( current_app.config["MINIMUM_PIECE_COUNT"], min( pieces, current_app.config["MAX_POINT_COST_FOR_REBUILDING"], ), ) if not (puzzleData["owner"] == user and puzzleData["puzzle_id"] == puzzleData["original_puzzle_id"]): cur.execute( fetch_query_string("decrease-user-points.sql"), { "user": user, "points": point_cost }, ) # Update puzzle status to be REBUILD and change the piece count cur.execute( fetch_query_string("update_status_puzzle_for_puzzle_id.sql"), { "puzzle_id": puzzle_id, "status": REBUILD, "pieces": pieces, "queue": QUEUE_REBUILD, }, ) puzzleData["status"] = REBUILD puzzleData["pieces"] = pieces db.commit() # Delete any piece data from redis since it is no longer needed. (all_pieces, col_names) = rowify( cur.execute( fetch_query_string("select_all_piece_ids_for_puzzle.sql"), { "puzzle": puzzle }, ).fetchall(), cur.description, ) cur.close() deletePieceDataFromRedis(redis_connection, puzzle, all_pieces) delete_puzzle_resources( puzzle_id, is_local_resource=not puzzleData["preview_full"].startswith("http") and not puzzleData["preview_full"].startswith("//"), exclude_regex=r"(original|preview_full).([^.]+\.)?jpg") job = current_app.createqueue.enqueue( "api.jobs.pieceRenderer.render", [puzzleData], result_ttl=0, job_timeout="24h", ) job = current_app.cleanupqueue.enqueue( "api.jobs.timeline_archive.archive_and_clear", puzzle, result_ttl=0, job_timeout="24h", ) purge_route_from_nginx_cache( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id), current_app.config.get("PURGEURLLIST"), ) return redirect( "/chill/site/front/{puzzle_id}/".format(puzzle_id=puzzle_id))