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
32 changes: 32 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#############################################################
# Variables for configuring GitHub OAuth App used for logging
#############################################################
# Create GitHub App on GitHub:
# - https://github.com/settings/apps/new
# - Set `GitHub App name` to something like `diffkemp-automation-login`
# - Set `Homepage URL` to something like `http://127.0.0.1:5000/` or your domain
# - Set `Callback URL` to `http://127.0.0.1:5000/auth/auth` or `domain/auth/auth`
# - Deactivate Webhook
# - No need to set any permissions
# - Create the GitHub App
# - No need to generate a private key as we will not install the app

# Client ID of the created GitHub App
GITHUB_LOGIN_CLIENT_ID=

# Client secret for creating access token
# Generate on the created GitHub App and paste it here
GITHUB_LOGIN_SECRET=...

# Comma separated list of GitHub logins
# for users which are allowed to modify data
GITHUB_LOGIN_ALLOWED_USERS=login1,login2

####
#
####

# Secret key for sessions
# https://flask.palletsprojects.com/en/stable/quickstart/#sessions
# Generate it with `python -c 'import secrets; print(secrets.token_hex())'`
APP_SESSION_SECRET_KEY=...
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.env

# Python specific
.venv/

*.pyc
Expand Down
17 changes: 14 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,27 @@ Backend server which manages:
pip install -e .[dev]
```

3. Run initial comparison:
3. Prepare DB:

```bash
alembic upgrade head
```

4. Run initial comparison:

```bash
diffkemp-automation-compare
```

4. After initial comparison is finished, setup automatic checking of new versions:
5. After initial comparison is finished, setup automatic checking of new versions:

```bash
backend/create_cron_job.sh
```

5. Running web server
6. Prepare `.env` file, create it based on `.env.example`

7. Running web server

```bash
diffkemp-automation-dev-web
Expand All @@ -59,3 +67,6 @@ mypy .
- The container is specified in `comparison_container/Dockerfile`.
- The container contains files from `comparison_container/files/` in `/runner/`
directory, for more details see `comparison_container/files/README.md`.
- For DB is used [SQLite](https://sqlite.org/), [SqlAlchemy](https://www.sqlalchemy.org/)
is used for ORM. For DB scheme migration is used [Alembic](https://alembic.sqlalchemy.org/en/latest/).
- For logging in is used [GitHub App](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-login-with-github-button-with-a-github-app).
10 changes: 10 additions & 0 deletions backend/automation/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,38 @@
:author: Lukas Petr
"""
import logging
import os
import sys

import gunicorn.app.wsgiapp as wsgiapp
from dotenv import load_dotenv
from flask import Flask, redirect, url_for
from flask.typing import ResponseReturnValue


def create_app() -> Flask:
load_dotenv()
app = Flask(__name__)

from .auth import auth_bp
from .commits import commits_bp
from .versions import versions_bp
from .viewer import view_results_bp

app.register_blueprint(versions_bp)
app.register_blueprint(commits_bp)
app.register_blueprint(view_results_bp)
app.register_blueprint(auth_bp)

@app.route("/")
def index() -> ResponseReturnValue:
return redirect(url_for("versions_bp.versions_list"))

session_secret_key = os.getenv("APP_SESSION_SECRET_KEY")
if not session_secret_key:
raise ValueError("APP_SESSION_SECRET_KEY must be set in .env")
app.secret_key = session_secret_key

return app


Expand Down
114 changes: 114 additions & 0 deletions backend/automation/web/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Module solving authentication of users."""
import logging
import os
from urllib.parse import urlparse

import requests
from flask import Blueprint, flash, redirect, request, session
from werkzeug.wrappers import Response

auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth")
logger = logging.getLogger(__package__)


def is_safe_redirect(target: str) -> bool:
"""Check if redirect URL is safe (same domain as request)."""
if not target:
return False

# Parse the target URL
parsed = urlparse(target)

# Allow relative URLs
if not parsed.scheme and not parsed.netloc:
return target.startswith("/") and not target.startswith("//")

# For absolute URLs, check if domain matches current request
request_host = urlparse(request.url).netloc
return parsed.netloc == request_host


@auth_bp.route("/login")
def login() -> Response:
"""For user log in via GitHub App OAuth."""
# Setting next for redirecting back to the original page after user is
# logged in.
next = request.args.get("next", "/")
session["oauth_next"] = next

if "GITHUB_LOGIN_CLIENT_ID" not in os.environ:
raise ValueError("Missing GITHUB_LOGIN_CLIENT_ID var")
client_id = os.getenv("GITHUB_LOGIN_CLIENT_ID", "")
github_oauth_url = (
"https://github.com/login/oauth/authorize"
f"?client_id={client_id}"
)
return redirect(github_oauth_url)


@auth_bp.route("/auth")
def auth() -> Response:
"""Called back from GitHub OAuth."""
code = request.args.get("code")
if not code:
flash("Authentication failed: No code received", "danger")
logger.error("Authentication failed: No code received")
return redirect("/")
secret = os.getenv("GITHUB_LOGIN_SECRET")
client_id = os.getenv("GITHUB_LOGIN_CLIENT_ID")
allowed_users = os.getenv("GITHUB_LOGIN_ALLOWED_USERS")

# Get access token
try:
res = requests.post(
"https://github.com/login/oauth/access_token"
f"?client_id={client_id}&client_secret={secret}&code={code}",
headers={"Accept": "application/json"}
)
res.raise_for_status()
data = res.json()
except Exception as e:
flash("GitHub authentication failed", "danger")
logger.error(f"Failed to authenticate: {e}")
return redirect("/")

access_token = data["access_token"]

# Get user information
try:
res = requests.get(
"https://api.github.com/user",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
})
res.raise_for_status()
data = res.json()
except Exception as e:
flash("Unable to get user information from GitHub", "danger")
logger.error(f"Unable to get user information from GitHub: {e}")
return redirect("/")

login = data["login"]
if allowed_users and login in allowed_users.split(","):
session["user"] = login
else:
flash("You are not on list of allowed users!!!", "danger")
logger.error(f"Attempt to log in from {login}")

# Redirecting back to the original page
next = session.pop("oauth_next", "/")
if not is_safe_redirect(next):
next = "/"
return redirect(next)


@auth_bp.route("/logout")
def logout() -> Response:
"""Logs out the user."""
session.pop("user", None)

next = request.args.get("next", "/")
if not is_safe_redirect(next):
next = "/"
return redirect(next)
59 changes: 44 additions & 15 deletions backend/automation/web/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,53 @@
</head>

<body>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} m-0" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<header>
<nav class="navbar bg-primary navbar-expand-lg" data-bs-theme="dark">
<nav class="navbar bg-primary navbar-expand navbar-inverse" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">DiffKemp Automation</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a
class="nav-link {% if request.endpoint.startswith('versions_bp') %}active{% endif %}"
href="{{url_for('versions_bp.versions_list')}}">Versions</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.endpoint.startswith('commits_bp') %}active{% endif %}"
href="{{url_for('commits_bp.commits_list')}}">Commits</a>
</li>
</ul>
</div>
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a
class="nav-link {% if request.endpoint.startswith('versions_bp') %}active{% endif %}"
href="{{url_for('versions_bp.versions_list')}}">Versions</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.endpoint.startswith('commits_bp') %}active{% endif %}"
href="{{url_for('commits_bp.commits_list')}}">Commits</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
{% if "user" not in session %}
<a href="{{url_for('auth_bp.login', next=request.url)}}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
Log in via GitHub
</a>
{% else %}
<a href="{{url_for('auth_bp.logout', next=request.url)}}" title="Logout">
<span>
{{session["user"]}}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708z"/>
</svg>
</span>
</a>
{% endif %}
</li>
</ul>
</div>
</nav>
</header>
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"gunicorn==23.0.0",
"sqlalchemy==2.0.45",
"alembic==1.17.2",
"python-dotenv==1.2.1"
]

[tool.poetry]
Expand Down