mirror of
https://github.com/kuhyx/WUT_Computer_Science.git
synced 2026-11-10 18:47:11 +01:00
156 ruff findings to 0. That takes the whole archive to 0, from 9,281.
Six of those findings were SQL injection, and they were the real ones. The
connector interpolated a username and an OAuth id straight out of the URL into
f-string SQL. With psycopg2 installed, the difference is not theoretical:
username = "x'; DROP TABLE users; --"
old: select * from users where username='x'; DROP TABLE users; --';
new: select * from users where username='x''; DROP TABLE users; --';
The old form is two statements and the second one runs. The new one is a
parameter, so the whole thing stays a string literal. Every query in that file
is parameterised now.
`get_recommendations` also called
`cursor.execute("... oauth_ID='{}'", oauth_ID)`, which is wrong twice over:
psycopg2 wants %s rather than {}, and its second argument must be a sequence,
not a bare string. That route could never have worked.
Ten blind `except Exception` handlers turned every programming error into a
500 with the message hidden. They are now the exceptions each block can
actually raise -- verified against the installed libraries, where
InvalidIdTokenError and ExpiredIdTokenError really are FirebaseError
subclasses -- and the handlers log a traceback instead of printing the
exception's str().
Three services bound `host="localhost"`. Each of them runs in a container with
a published port, on a compose bridge network, so binding loopback makes them
unreachable from both the host and their siblings. All four now read
ERSMS_HOST, which docker-compose sets to 0.0.0.0, and default to loopback when
run outside Docker. Flask's debug console executes arbitrary code, so that is
opt-in via FLASK_DEBUG too.
`movie_recommender.py` fitted the model at import: reading two CSVs and
building a 4803x4803 similarity matrix as a side effect of `import`. It failed
outright here, since the datasets are not committed. It is in `main()` now, and
the module imports cleanly. `error_decorator` was dead code that swallowed its
wrapped function's return value; it is gone.
Verified: all four modules import (the backend still needs its Firebase
credential JSON, which is not committed, exactly as before), the recommender's
helpers still pick the director out of a crew list, trim cast to three, fold
"Tom Hanks" to "tomhanks" and join a soup in the same order, and every route
parameter rename was matched by the URL rule it comes from.
Three of the four services are clean under mypy --strict. The backend keeps
four errors: flask-sqlalchemy builds `db.Model` dynamically, so `--strict`
refuses to subclass it. Migrating to a typed DeclarativeBase would rewrite both
models in an app that cannot be run here, so those four are reported in
TODO-refactor-remaining.md rather than hidden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
8.1 KiB
Python
Executable File
237 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""The Firebase-authenticated API: log in, rate a movie, look films up on TMDB.
|
|
|
|
Every route takes a Firebase ID token in the request body, verifies it, and
|
|
uses the uid inside it as the identity. Ratings live in a local SQLite file;
|
|
film metadata is proxied from TMDB.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import TYPE_CHECKING
|
|
|
|
import firebase_admin
|
|
import requests
|
|
from dotenv import load_dotenv
|
|
from firebase_admin import auth, credentials, exceptions
|
|
from flask import Flask, jsonify, request
|
|
from flask_cors import CORS
|
|
from flask_sqlalchemy import SQLAlchemy
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
if TYPE_CHECKING:
|
|
from flask.typing import ResponseReturnValue
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
load_dotenv()
|
|
TMDB_BEARER_TOKEN = os.getenv("TMDB_BEARER_TOKEN")
|
|
|
|
# TMDB is a third party over the internet; never wait on it indefinitely.
|
|
TMDB_TIMEOUT_SECONDS = 10
|
|
|
|
HTTP_OK = 200
|
|
HTTP_CREATED = 201
|
|
HTTP_UNAUTHORIZED = 401
|
|
HTTP_INTERNAL_SERVER_ERROR = 500
|
|
|
|
# A token that will not verify, and a request the token owner may not make.
|
|
AUTH_ERRORS = (exceptions.FirebaseError, ValueError, KeyError)
|
|
|
|
app = Flask(__name__)
|
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.sqlite3"
|
|
app.config["SQLALCHEY_TRACK_MODIFICATIONS"] = False
|
|
|
|
db = SQLAlchemy(app)
|
|
|
|
CORS(app)
|
|
|
|
cred = credentials.Certificate("movie-recommendation-firebase-adminsdk.json")
|
|
firebase_admin.initialize_app(cred)
|
|
|
|
|
|
class User(db.Model):
|
|
"""One Firebase account, remembered so ratings have an owner."""
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
uid = db.Column(db.String(80), unique=True, nullable=False)
|
|
email = db.Column(db.String(80), unique=True, nullable=False)
|
|
is_admin = db.Column(db.Boolean, default=False)
|
|
|
|
|
|
class Rating(db.Model):
|
|
"""One user's score for one film."""
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey("user.uid"), nullable=False)
|
|
movie_id = db.Column(db.Integer, nullable=False)
|
|
value = db.Column(db.Integer, nullable=False)
|
|
|
|
|
|
with app.app_context():
|
|
db.create_all()
|
|
|
|
|
|
@app.route("/login", methods=["POST"])
|
|
def login() -> ResponseReturnValue:
|
|
"""Verify the token and create the user row if this is their first visit."""
|
|
token = request.json.get("token")
|
|
try:
|
|
decoded_token = auth.verify_id_token(token)
|
|
uid = decoded_token["uid"]
|
|
logger.info("login: %s", uid)
|
|
email = decoded_token.get("email")
|
|
|
|
user = User.query.filter_by(uid=uid).first()
|
|
if user is None:
|
|
user = User(uid=uid, email=email)
|
|
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
except (*AUTH_ERRORS, SQLAlchemyError):
|
|
logger.exception("Login failed")
|
|
return jsonify({"message": "Login failed"}), HTTP_UNAUTHORIZED
|
|
return jsonify(
|
|
{"message": "Login successful!", "email": email, "is_admin": user.is_admin}
|
|
), HTTP_OK
|
|
|
|
|
|
@app.route("/count_user_ratings", methods=["POST"])
|
|
def count_user_ratings() -> ResponseReturnValue:
|
|
"""Return how many films this user has rated."""
|
|
token = request.json.get("token")
|
|
try:
|
|
decoded_token = auth.verify_id_token(token)
|
|
user_id = decoded_token["uid"]
|
|
|
|
user = User.query.filter_by(uid=user_id).first()
|
|
if user is None:
|
|
return jsonify({"message": "Error"}), HTTP_INTERNAL_SERVER_ERROR
|
|
|
|
rating_count = Rating.query.filter_by(user_id=user_id).count()
|
|
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
|
|
logger.exception("Could not count ratings")
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify(
|
|
{"message": "Ratings counted!", "rating_count": rating_count}
|
|
), HTTP_OK
|
|
|
|
|
|
@app.route("/movie/<int:movie_id>", methods=["GET"])
|
|
def get_tmdb_data_movie_id(movie_id: int) -> ResponseReturnValue:
|
|
"""Proxy one film's TMDB record."""
|
|
url = f"https://api.themoviedb.org/3/movie/{movie_id}"
|
|
|
|
headers = {"Authorization": f"Bearer {TMDB_BEARER_TOKEN}"}
|
|
|
|
try:
|
|
response = requests.get(url, headers=headers, timeout=TMDB_TIMEOUT_SECONDS)
|
|
response.raise_for_status()
|
|
except requests.RequestException as exc:
|
|
logger.exception("TMDB lookup failed for %s", movie_id)
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify(response.json()), HTTP_OK
|
|
|
|
|
|
@app.route("/movie", methods=["GET"])
|
|
def get_tmdb_data_query() -> ResponseReturnValue:
|
|
"""Search TMDB, or return what is trending today when there is no query."""
|
|
query = request.args.get("query")
|
|
|
|
if query:
|
|
url = f"https://api.themoviedb.org/3/search/movie?query={query}"
|
|
else:
|
|
url = "https://api.themoviedb.org/3/trending/movie/day"
|
|
|
|
headers = {"Authorization": f"Bearer {TMDB_BEARER_TOKEN}"}
|
|
|
|
try:
|
|
response = requests.get(url, headers=headers, timeout=TMDB_TIMEOUT_SECONDS)
|
|
response.raise_for_status()
|
|
except requests.RequestException as exc:
|
|
logger.exception("TMDB search failed")
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify(response.json()), HTTP_OK
|
|
|
|
|
|
@app.route("/rating", methods=["POST"])
|
|
def add_rating() -> ResponseReturnValue:
|
|
"""Record this user's score for a film, or update it if they had one."""
|
|
token = request.json.get("token")
|
|
movie = request.json.get("movie")
|
|
value = request.json.get("value")
|
|
try:
|
|
decoded_token = auth.verify_id_token(token)
|
|
user_id = decoded_token["uid"]
|
|
|
|
rating = Rating.query.filter_by(user_id=user_id, movie_id=movie).first()
|
|
if rating is None:
|
|
rating = Rating(user_id=user_id, movie_id=movie, value=value)
|
|
|
|
db.session.add(rating)
|
|
db.session.commit()
|
|
|
|
return jsonify({"message": "Rating added successfully!"}), HTTP_CREATED
|
|
rating.value = value
|
|
|
|
db.session.commit()
|
|
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
|
|
logger.exception("Could not add rating")
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify({"message": "Rating updated successfully!"}), HTTP_OK
|
|
|
|
|
|
@app.route("/rating", methods=["DELETE"])
|
|
def remove_rating() -> ResponseReturnValue:
|
|
"""Delete this user's score for a film."""
|
|
token = request.json.get("token")
|
|
movie = request.json.get("movie")
|
|
try:
|
|
decoded_token = auth.verify_id_token(token)
|
|
user_id = decoded_token["uid"]
|
|
|
|
rating = Rating.query.filter_by(user_id=user_id, movie_id=movie).first()
|
|
if rating is None:
|
|
return jsonify({"message": "Error"}), HTTP_INTERNAL_SERVER_ERROR
|
|
|
|
db.session.delete(rating)
|
|
db.session.commit()
|
|
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
|
|
logger.exception("Could not remove rating")
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify({"message": "Rating removed successfully!"}), HTTP_OK
|
|
|
|
|
|
@app.route("/get_rating", methods=["POST"])
|
|
def get_rating() -> ResponseReturnValue:
|
|
"""Return this user's score for a film, if they have given one."""
|
|
token = request.json.get("token")
|
|
movie = request.json.get("movie")
|
|
try:
|
|
decoded_token = auth.verify_id_token(token)
|
|
user_id = decoded_token["uid"]
|
|
|
|
rating = Rating.query.filter_by(user_id=user_id, movie_id=movie).first()
|
|
if rating is None:
|
|
return jsonify({"message": "Rating not found!"}), HTTP_OK
|
|
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
|
|
logger.exception("Could not read rating")
|
|
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
|
|
return jsonify(
|
|
{
|
|
"message": "Rating found!",
|
|
"movie": rating.movie_id,
|
|
"value": rating.value,
|
|
}
|
|
), HTTP_OK
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
|
# Inside a container the service must bind every interface or the
|
|
# published port is unreachable, so docker-compose sets ERSMS_HOST. The
|
|
# default is loopback, which is the safe thing to do outside Docker.
|
|
app.run(host=os.environ.get("ERSMS_HOST", "127.0.0.1"), port=8084)
|