WUT_Computer_Science/Programming/ERSMS-project/backend/app.py
Krzysztof kuhy Rudnicki f58f55f68b
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
runnability / runnability (push) Waiting to run
ERSMS-project: zero findings, and the SQL is parameterised now
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>
2026-08-29 08:30:01 +02:00

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)