mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
As repeats of these traceback notifications were annoying some of the devops staff, and it took coordination to share tracebacks with developers, this commit removes that responsibility off of devops by reporting tracebacks to Gitlab repositories in the form of issues. - removed ServerErrorNotification - removed notifications.postmaster configuration option - added notifications.gitlab-instance option - added notifications.error-project option - added notifications.error-token option - added aurweb.exceptions.handle_form_exceptions, a POST route decorator Issues are filed confidentially. This change will need updates in infrastructure's ansible configuration before this can be applied to aur.archlinux.org. Signed-off-by: Kevin Morris <kevr@0cost.org>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
import hashlib
|
|
import http
|
|
import io
|
|
import os
|
|
import re
|
|
import sys
|
|
import traceback
|
|
import typing
|
|
|
|
from urllib.parse import quote_plus
|
|
|
|
import requests
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
from fastapi.responses import RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from jinja2 import TemplateNotFound
|
|
from prometheus_client import multiprocess
|
|
from sqlalchemy import and_, or_
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
import aurweb.captcha # noqa: F401
|
|
import aurweb.config
|
|
import aurweb.filters # noqa: F401
|
|
import aurweb.logging
|
|
import aurweb.pkgbase.util as pkgbaseutil
|
|
|
|
from aurweb import logging, prometheus, util
|
|
from aurweb.auth import BasicAuthBackend
|
|
from aurweb.db import get_engine, query
|
|
from aurweb.models import AcceptedTerm, Term
|
|
from aurweb.packages.util import get_pkg_or_base
|
|
from aurweb.prometheus import instrumentator
|
|
from aurweb.redis import redis_connection
|
|
from aurweb.routers import APP_ROUTES
|
|
from aurweb.templates import make_context, render_template
|
|
|
|
logger = logging.get_logger(__name__)
|
|
|
|
# Setup the FastAPI app.
|
|
app = FastAPI()
|
|
|
|
# Instrument routes with the prometheus-fastapi-instrumentator
|
|
# library with custom collectors and expose /metrics.
|
|
instrumentator().add(prometheus.http_api_requests_total())
|
|
instrumentator().add(prometheus.http_requests_total())
|
|
instrumentator().instrument(app)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def app_startup():
|
|
# https://stackoverflow.com/questions/67054759/about-the-maximum-recursion-error-in-fastapi
|
|
# Test failures have been observed by internal starlette code when
|
|
# using starlette.testclient.TestClient. Looking around in regards
|
|
# to the recursion error has really not recommended a course of action
|
|
# other than increasing the recursion limit. For now, that is how
|
|
# we handle the issue: an optional TEST_RECURSION_LIMIT env var
|
|
# provided by the user. Docker uses .env's TEST_RECURSION_LIMIT
|
|
# when running test suites.
|
|
# TODO: Find a proper fix to this issue.
|
|
recursion_limit = int(os.environ.get(
|
|
"TEST_RECURSION_LIMIT", sys.getrecursionlimit() + 1000))
|
|
sys.setrecursionlimit(recursion_limit)
|
|
|
|
backend = aurweb.config.get("database", "backend")
|
|
if backend not in aurweb.db.DRIVERS:
|
|
raise ValueError(
|
|
f"The configured database backend ({backend}) is unsupported. "
|
|
f"Supported backends: {str(aurweb.db.DRIVERS.keys())}")
|
|
|
|
session_secret = aurweb.config.get("fastapi", "session_secret")
|
|
if not session_secret:
|
|
raise Exception("[fastapi] session_secret must not be empty")
|
|
|
|
app.mount("/static/css",
|
|
StaticFiles(directory="web/html/css"),
|
|
name="static_css")
|
|
app.mount("/static/js",
|
|
StaticFiles(directory="web/html/js"),
|
|
name="static_js")
|
|
app.mount("/static/images",
|
|
StaticFiles(directory="web/html/images"),
|
|
name="static_images")
|
|
|
|
# Add application middlewares.
|
|
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
|
|
app.add_middleware(SessionMiddleware, secret_key=session_secret)
|
|
|
|
# Add application routes.
|
|
def add_router(module):
|
|
app.include_router(module.router)
|
|
util.apply_all(APP_ROUTES, add_router)
|
|
|
|
# Initialize the database engine and ORM.
|
|
get_engine()
|
|
|
|
|
|
def child_exit(server, worker): # pragma: no cover
|
|
""" This function is required for gunicorn customization
|
|
of prometheus multiprocessing. """
|
|
multiprocess.mark_process_dead(worker.pid)
|
|
|
|
|
|
async def internal_server_error(request: Request, exc: Exception) -> Response:
|
|
"""
|
|
Catch all uncaught Exceptions thrown in a route.
|
|
|
|
:param request: FastAPI Request
|
|
:return: Rendered 500.html template with status_code 500
|
|
"""
|
|
repo = aurweb.config.get("notifications", "gitlab-instance")
|
|
project = aurweb.config.get("notifications", "error-project")
|
|
token = aurweb.config.get("notifications", "error-token")
|
|
|
|
context = make_context(request, "Internal Server Error")
|
|
|
|
# Print out the exception via `traceback` and store the value
|
|
# into the `traceback` context variable.
|
|
tb_io = io.StringIO()
|
|
traceback.print_exc(file=tb_io)
|
|
tb = tb_io.getvalue()
|
|
context["traceback"] = tb
|
|
|
|
# Produce a SHA1 hash of the traceback string.
|
|
tb_hash = hashlib.sha1(tb.encode()).hexdigest()
|
|
tb_id = tb_hash[:7]
|
|
|
|
redis = redis_connection()
|
|
key = f"tb:{tb_hash}"
|
|
retval = redis.get(key)
|
|
if not retval:
|
|
# Expire in one hour; this is just done to make sure we
|
|
# don't infinitely store these values, but reduce the number
|
|
# of automated reports (notification below). At this time of
|
|
# writing, unexpected exceptions are not common, thus this
|
|
# will not produce a large memory footprint in redis.
|
|
pipe = redis.pipeline()
|
|
pipe.set(key, tb)
|
|
pipe.expire(key, 86400) # One day.
|
|
pipe.execute()
|
|
|
|
# Send out notification about it.
|
|
if "set-me" not in (project, token):
|
|
proj = quote_plus(project)
|
|
endp = f"{repo}/api/v4/projects/{proj}/issues"
|
|
|
|
base = f"{request.url.scheme}://{request.url.netloc}"
|
|
title = f"Traceback [{tb_id}]: {base}{request.url.path}"
|
|
desc = [
|
|
"DISCLAIMER",
|
|
"----------",
|
|
"**This issue is confidential** and should be sanitized "
|
|
"before sharing with users or developers. Please ensure "
|
|
"you've completed the following tasks:",
|
|
"- [ ] I have removed any sensitive data and "
|
|
"the description history.",
|
|
"",
|
|
"Exception Details",
|
|
"-----------------",
|
|
f"- Route: `{request.url.path}`",
|
|
f"- User: `{request.user.Username}`",
|
|
f"- Email: `{request.user.Email}`",
|
|
]
|
|
|
|
# Add method-specific information to the description.
|
|
if request.method.lower() == "get":
|
|
# get
|
|
if request.url.query:
|
|
desc = desc + [f"- Query: `{request.url.query}`"]
|
|
desc += ["", f"```{tb}```"]
|
|
else:
|
|
# post
|
|
form_data = str(dict(request.state.form_data))
|
|
desc = desc + [
|
|
f"- Data: `{form_data}`"
|
|
] + ["", f"```{tb}```"]
|
|
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
data = {
|
|
"title": title,
|
|
"description": "\n".join(desc),
|
|
"labels": ["triage"],
|
|
"confidential": True,
|
|
}
|
|
logger.info(endp)
|
|
resp = requests.post(endp, json=data, headers=headers)
|
|
if resp.status_code != http.HTTPStatus.CREATED:
|
|
logger.error(
|
|
f"Unable to report exception to {repo}: {resp.text}")
|
|
else:
|
|
logger.warning("Unable to report an exception found due to "
|
|
"unset notifications.error-{{project,token}}")
|
|
|
|
# Log details about the exception traceback.
|
|
logger.error(f"FATAL[{tb_id}]: An unexpected exception has occurred.")
|
|
logger.error(tb)
|
|
else:
|
|
retval = retval.decode()
|
|
|
|
return render_template(request, "errors/500.html", context,
|
|
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException) \
|
|
-> Response:
|
|
""" Handle an HTTPException thrown in a route. """
|
|
phrase = http.HTTPStatus(exc.status_code).phrase
|
|
context = make_context(request, phrase)
|
|
context["exc"] = exc
|
|
context["phrase"] = phrase
|
|
|
|
# Additional context for some exceptions.
|
|
if exc.status_code == http.HTTPStatus.NOT_FOUND:
|
|
tokens = request.url.path.split("/")
|
|
matches = re.match("^([a-z0-9][a-z0-9.+_-]*?)(\\.git)?$", tokens[1])
|
|
if matches:
|
|
try:
|
|
pkgbase = get_pkg_or_base(matches.group(1))
|
|
context = pkgbaseutil.make_context(request, pkgbase)
|
|
except HTTPException:
|
|
pass
|
|
|
|
try:
|
|
return render_template(request, f"errors/{exc.status_code}.html",
|
|
context, exc.status_code)
|
|
except TemplateNotFound:
|
|
return render_template(request, "errors/detail.html",
|
|
context, exc.status_code)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def add_security_headers(request: Request, call_next: typing.Callable):
|
|
""" This middleware adds the CSP, XCTO, XFO and RP security
|
|
headers to the HTTP response associated with request.
|
|
|
|
CSP: Content-Security-Policy
|
|
XCTO: X-Content-Type-Options
|
|
RP: Referrer-Policy
|
|
XFO: X-Frame-Options
|
|
"""
|
|
try:
|
|
response = await util.error_or_result(call_next, request)
|
|
except Exception as exc:
|
|
return await internal_server_error(request, exc)
|
|
|
|
# Add CSP header.
|
|
nonce = request.user.nonce
|
|
csp = "default-src 'self'; "
|
|
script_hosts = []
|
|
csp += f"script-src 'self' 'nonce-{nonce}' " + ' '.join(script_hosts)
|
|
# It's fine if css is inlined.
|
|
csp += "; style-src 'self' 'unsafe-inline'"
|
|
response.headers["Content-Security-Policy"] = csp
|
|
|
|
# Add XTCO header.
|
|
xcto = "nosniff"
|
|
response.headers["X-Content-Type-Options"] = xcto
|
|
|
|
# Add Referrer Policy header.
|
|
rp = "same-origin"
|
|
response.headers["Referrer-Policy"] = rp
|
|
|
|
# Add X-Frame-Options header.
|
|
xfo = "SAMEORIGIN"
|
|
response.headers["X-Frame-Options"] = xfo
|
|
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def check_terms_of_service(request: Request, call_next: typing.Callable):
|
|
""" This middleware function redirects authenticated users if they
|
|
have any outstanding Terms to agree to. """
|
|
if request.user.is_authenticated() and request.url.path != "/tos":
|
|
unaccepted = query(Term).join(AcceptedTerm).filter(
|
|
or_(AcceptedTerm.UsersID != request.user.ID,
|
|
and_(AcceptedTerm.UsersID == request.user.ID,
|
|
AcceptedTerm.TermsID == Term.ID,
|
|
AcceptedTerm.Revision < Term.Revision)))
|
|
if query(Term).count() > unaccepted.count():
|
|
return RedirectResponse(
|
|
"/tos", status_code=int(http.HTTPStatus.SEE_OTHER))
|
|
|
|
return await util.error_or_result(call_next, request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def id_redirect_middleware(request: Request, call_next: typing.Callable):
|
|
id = request.query_params.get("id")
|
|
|
|
if id is not None:
|
|
# Preserve query string.
|
|
qs = []
|
|
for k, v in request.query_params.items():
|
|
if k != "id":
|
|
qs.append(f"{k}={quote_plus(str(v))}")
|
|
qs = str() if not qs else '?' + '&'.join(qs)
|
|
|
|
path = request.url.path.rstrip('/')
|
|
return RedirectResponse(f"{path}/{id}{qs}")
|
|
|
|
return await util.error_or_result(call_next, request)
|