Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
419 changes: 229 additions & 190 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ packages = [{ include = "src" }]

[tool.poetry.dependencies]
# command: `poetry add <package-name>`
python = ">3.9.7"
python = ">=3.10"
flask = "3.0.3"
flask-cors = "5.0.0"
g4f = "0.3.2.2"
geopy = "2.4.1"
openmeteo-requests = "1.2.0"
pandas = "2.2.2"
pydantic = "2.7.2"
pydantic = "^2.12.5"
pydantic-settings = "2.2.1"
python-dotenv = "1.0.1"
requests = "2.32.4"
Expand All @@ -30,6 +30,9 @@ matplotlib = "3.9.0"
seaborn = "^0.13.2"
numpy = "^1.26.4"
pymongo = {extras = ["gssapi", "snappy", "srv", "tls"], version = "^4.15.4"}
fastapi = "^0.135.2"
uvicorn = "^0.42.0"
httpx = "^0.28.1"

[tool.poetry.group.dev.dependencies]
# command: `poetry add --group dev <package-name>`
Expand Down
78 changes: 39 additions & 39 deletions src/server.py
Original file line number Diff line number Diff line change
@@ -1,84 +1,84 @@
"""
Flask Server!
FastAPI Server!
"""

import logging
import subprocess
import sys
import urllib.parse
from pathlib import Path

from flask import (
Flask,
render_template,
request,
send_file,
send_from_directory,
)
from flask_cors import CORS
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates

from src.settings import ServerSettings

logger = logging.getLogger(__name__)

BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR / "src" / "templates"
CLI_PATH = BASE_DIR / "src" / "cli.py"

templates = Jinja2Templates(directory=str(TEMPLATES_DIR))


def create_app(env):
"""
Application factory function
"""
app = Flask(__name__)
CORS(app)
app = FastAPI()

# define which "origins" (frontend urls) can talk to this api
origins = ["http://localhost:8501", "http://127.0.0.1:8501"]

app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): CORS is restricted to GET only, which may be too tight if non-GET endpoints are introduced.

Compared to the previous CORS(app) usage, this configuration will cause any future POST/PUT/DELETE routes to fail CORS preflight even though FastAPI accepts them. If you expect to add non-GET endpoints, consider either listing those methods explicitly or using a broader set of standard methods to avoid hard-to-diagnose frontend errors.

Suggested change
allow_methods=["GET"],
# Allow standard HTTP methods so future non-GET endpoints won't be blocked by CORS
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],

allow_headers=["*"],
)

@app.route("/help")
def serve_help():
@app.get("/help")
async def serve_help():
"""Serves the help.txt file."""
return send_from_directory(
Path(__file__).resolve().parents[1], "help.txt"
)

@app.route("/home")
def serve_index():
"""Serves index.html."""
return render_template("index.html", env_vars=env.model_dump())

@app.route("/script.js")
def serve_script():
"""Serves the frontend JavaScript."""
return send_file("static/script.js")

@app.route("/")
def default_route():
HELP_FILE_PATH = BASE_DIR / "help.txt"
return FileResponse(path=HELP_FILE_PATH, media_type="text/plain")

@app.get("/", response_class=PlainTextResponse)
async def default_route(request: Request):
"""Serves the surf report."""
query_parameters = urllib.parse.parse_qsl(
request.query_string.decode(), keep_blank_values=True
)
parsed_parameters = [
f"{key}={value}" if value else key
for key, value in query_parameters
for key, value in request.query_params.items()
Comment on lines +50 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Query parameter handling now drops duplicate keys compared to the previous implementation.

urllib.parse.parse_qsl(..., keep_blank_values=True) preserved multiple values per key (e.g. ?tag=a&tag=b), but request.query_params.items() only exposes the last value, so duplicates are lost. If the CLI depends on multiple values for a key, this is a behavior change. To keep parity, use an API that preserves duplicates (e.g. request.query_params.multi_items()) when building parsed_parameters.

]
args = ",".join(parsed_parameters)

try:
result = subprocess.run(
[sys.executable, Path("src") / "cli.py", args],
[sys.executable, str(CLI_PATH), args],
capture_output=True,
text=True,
check=True,
Comment on lines 60 to 64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (python.lang.security.audit.dangerous-subprocess-use-audit): Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

Source: opengrep

)
return result.stdout
except subprocess.CalledProcessError as e:
logger.error("Subprocess error: %s", e.stderr)
raise
raise HTTPException(status_code=500, detail="Internal CLI Error")

return app


env = ServerSettings()
app = create_app(env)

if __name__ == "__main__": # pragma: no cover
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
env = ServerSettings()
app = create_app(env)
app.run(host="0.0.0.0", port=env.PORT, debug=env.DEBUG)

uvicorn.run(app, host=str(env.IP_ADDRESS), port=env.PORT)
1 change: 0 additions & 1 deletion src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class ServerSettings(CommonSettings):
IP_ADDRESS: Union[IPvAnyAddress | Literal["localhost"]] = Field(
default="localhost"
)
DEBUG: bool = Field(default=True)


class EmailSettings(ServerSettings):
Expand Down
4 changes: 2 additions & 2 deletions src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@
</div>
</div>
<script>
// load .env variables from Flask server
const env = {{ env_vars|tojson }};
Comment on lines -89 to -90
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Switching from tojson to safe risks invalid JavaScript or injection issues.

tojson guarantees a valid JS literal and handles quoting/escaping for you. env_vars | safe assumes env_vars is already a correctly JSON-serialized string and disables escaping. If env_vars is a Python dict, it will render invalid JS (e.g. {'key': 'val'}) and, if it includes user-controlled data, can introduce XSS. Unless you’re passing a pre-serialized JSON string you fully control, use the FastAPI/Jinja equivalent of tojson (or explicit json.dumps) instead of safe.

// load .env variables from FastAPI server
const env = {{ env_vars | safe }};
</script>
<script src="{{ url_for('serve_script') }}"></script>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Template still references url_for('serve_script') although the serve_script route has been removed.

This reference will now fail at render time, since the Flask serve_script route no longer exists. Please either point this to the appropriate FastAPI route (with a matching name) or change it to a static script URL that FastAPI serves correctly.

</body>
Expand Down
6 changes: 4 additions & 2 deletions tests/test_help_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from http import HTTPStatus

from fastapi.testclient import TestClient

from src.server import create_app
from src.settings import ServerSettings

Expand All @@ -8,8 +10,8 @@ def test_help_endpoint_returns_200():
env = ServerSettings()
app = create_app(env)

client = app.test_client()
client = TestClient(app)
resp = client.get("/help")

assert resp.status_code == HTTPStatus.OK
assert len(resp.data) > 0
assert len(resp.text) > 0
6 changes: 4 additions & 2 deletions tests/test_root.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import subprocess
from http import HTTPStatus

from fastapi.testclient import TestClient

from src.server import create_app
from src.settings import ServerSettings

Expand All @@ -18,8 +20,8 @@ def fake_run(*args, **kwargs):

monkeypatch.setattr(subprocess, "run", fake_run)

client = app.test_client()
client = TestClient(app)
resp = client.get("/")

assert resp.status_code == HTTPStatus.OK
assert b"ok" in resp.data
assert "ok" in resp.text
22 changes: 3 additions & 19 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import subprocess
from http import HTTPStatus
from unittest.mock import patch

from fastapi.testclient import TestClient

from src.server import create_app
from src.settings import ServerSettings
Expand All @@ -14,23 +15,6 @@ def _make_app():
return create_app(ServerSettings())


def test_serve_index_returns_200(monkeypatch):
"""GET /home renders the index template and returns 200."""
app = _make_app()
with patch("src.server.render_template", return_value="<html>home</html>"):
resp = app.test_client().get("/home")
assert resp.status_code == HTTPStatus.OK
assert b"<html>home</html>" in resp.data


def test_serve_script_returns_200(monkeypatch):
"""GET /script.js serves the JavaScript file and returns 200."""
app = _make_app()
with patch("src.server.send_file", return_value="console.log('ok')"):
resp = app.test_client().get("/script.js")
assert resp.status_code == HTTPStatus.OK


def test_root_subprocess_error_returns_500(monkeypatch):
"""GET / returns 500 and logs the error when the subprocess fails."""
app = _make_app()
Expand All @@ -39,5 +23,5 @@ def fail_run(*args, **kwargs):
raise subprocess.CalledProcessError(1, "cmd", stderr="boom")

monkeypatch.setattr(subprocess, "run", fail_run)
resp = app.test_client().get("/")
resp = TestClient(app).get("/")
assert resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
Loading