Files
snikket-web-portal/snikket_web/__init__.py
Matthew Wild 6407eb90db Explicitly set cookie SameSite attribute to Lax
With 'Secure' set, it may default to 'None', which we don't need or want.

'Strict' is not suitable for session cookies - the user would see the login
screen when navigating from another site (e.g. hosting dashboard) and we
already have CSRF protection on forms.
2024-04-29 11:18:55 +01:00

307 lines
8.6 KiB
Python

import base64
import binascii
import logging
import os
import pathlib
import typing
import aiohttp
import quart.flask_patch
import quart
from quart import (
url_for,
render_template,
current_app,
redirect,
jsonify,
)
import werkzeug.exceptions
import environ
from . import colour, infra
from ._version import version # noqa:F401
async def proc() -> typing.Dict[str, typing.Any]:
def url_for_avatar(entity: str, hash_: str,
**kwargs: typing.Any) -> str:
return url_for(
"main.avatar",
from_=base64.urlsafe_b64encode(
entity.encode("utf-8"),
).decode("ascii").rstrip("="),
code=base64.urlsafe_b64encode(
binascii.a2b_hex(hash_)[:8],
).decode("ascii").rstrip("="),
**kwargs
)
try:
user_info = await infra.client.get_user_info()
except (aiohttp.ClientError, werkzeug.exceptions.HTTPException):
user_info = {}
return {
"url_for_avatar": url_for_avatar,
"text_to_css": colour.text_to_css,
"lang": infra.selected_locale(),
"user_info": user_info,
"is_in_debug_mode": current_app.debug,
}
def autosplit(s: typing.Union[str, typing.List[str]]) -> typing.List[str]:
if isinstance(s, str):
return s.split()
return s
async def render_exception_template(
template: str,
exc: Exception,
error_id: str,
) -> str:
more: typing.Dict[str, str] = {}
if current_app.debug:
import traceback
more.update(
traceback="".join(traceback.format_exception(
type(exc),
exc,
exc.__traceback__,
)),
)
return await render_template(
template,
exception_short=str(
".".join([
type(exc).__module__,
type(exc).__qualname__,
]),
),
error_id=error_id,
**more,
)
async def backend_error_handler(exc: Exception) -> quart.Response:
error_id = infra.generate_error_id()
current_app.logger.error(
"error_id=%s returning 503 status page for exception",
error_id,
exc_info=exc,
)
return quart.Response(
await render_exception_template(
"backend_error.html",
exc,
error_id,
),
status=503,
)
async def generic_http_error(
exc: werkzeug.exceptions.HTTPException,
) -> quart.Response:
return quart.Response(
await render_template(
"generic_http_error.html",
status=exc.code,
description=exc.description,
name=exc.name,
),
status=exc.code,
)
async def generic_error_handler(
exc: Exception,
) -> quart.Response:
error_id = infra.generate_error_id()
current_app.logger.error(
"error_id=%s returning 500 status page for exception",
error_id,
exc_info=exc,
)
return quart.Response(
await render_exception_template(
"internal_error.html",
exc,
error_id,
),
status=500,
)
@environ.config(prefix="SNIKKET_WEB")
class AppConfig:
secret_key = environ.var()
prosody_endpoint = environ.var()
domain = environ.var()
site_name = environ.var("")
avatar_cache_ttl = environ.var(1800, converter=int)
languages = environ.var([
# Keep `en` as the first language, because it is used as a fallback
# if the language negotiation cannot find another match. It is more
# likely that users are able to read english (or find a suitable
# online translator) than, for instance, danish.
"en",
"da",
"de",
"fr",
"id",
"it",
"pl",
"ru",
"sv",
"uk",
"zh_Hans_CN",
], converter=autosplit)
apple_store_url = environ.var(
"https://apps.apple.com/us/app/snikket/id1545164189",
)
# Default limit of 1 MiB is what was discovered to be the effective limit
# in #67, hence we set that here for now.
# Future versions may change this default, and the standard deployment
# tools may also very well override it.
max_avatar_size = environ.var(1024*1024, converter=int)
show_metrics = environ.bool_var(True)
tos_uri = environ.var("")
privacy_uri = environ.var("")
abuse_email = environ.var("")
security_email = environ.var("")
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
def create_app() -> quart.Quart:
try:
env_init = os.environ["SNIKKET_WEB_PYENV"]
except KeyError:
pass
else:
import runpy
init_vars = runpy.run_path(env_init)
for name, value in init_vars.items():
if not name:
continue
if name[0] not in _UPPER_CASE:
continue
os.environ[name] = value
config = environ.to_config(AppConfig)
app = quart.Quart(__name__)
app.config["LANGUAGES"] = config.languages
app.config["SECRET_KEY"] = config.secret_key
app.config["PROSODY_ENDPOINT"] = config.prosody_endpoint
app.config["SNIKKET_DOMAIN"] = config.domain
app.config["SITE_NAME"] = config.site_name or config.domain
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
app.config["APPLE_STORE_URL"] = config.apple_store_url
app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size
app.config["SHOW_METRICS"] = config.show_metrics
app.config["TOS_URI"] = config.tos_uri
app.config["PRIVACY_URI"] = config.privacy_uri
app.config["ABUSE_EMAIL"] = config.abuse_email
app.config["SECURITY_EMAIL"] = config.security_email
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.context_processor(proc)
app.register_error_handler(
aiohttp.ClientConnectorError,
backend_error_handler,
)
app.register_error_handler(
werkzeug.exceptions.HTTPException,
generic_http_error, # type:ignore
)
app.register_error_handler(
Exception,
generic_error_handler,
)
@app.route("/")
async def index() -> werkzeug.Response:
if infra.client.has_session:
return redirect(url_for('user.index'))
return redirect(url_for('main.login'))
@app.route("/site.webmanifest")
def site_manifest() -> quart.Response:
# this is needed for icons
return jsonify(
{
"name": "Snikket",
"short_name": "Snikket",
"icons": [
{
"src": url_for(
"static",
filename="img/android-chrome-192x192.png",
),
"sizes": "192x192",
"type": "image/png"
},
{
"src": url_for(
"static",
filename="img/android-chrome-256x256.png",
),
"sizes": "256x256",
"type": "image/png"
},
{
"src": url_for(
"static",
filename="img/android-chrome-512x512.png",
),
"sizes": "512x512",
"type": "image/png"
},
],
"theme_color": "#fbfdff",
"background_color": "#fbfdff",
}
)
logging_config = app.config.get("LOGGING_CONFIG")
if logging_config is not None:
if isinstance(logging_config, dict):
logging.config.dictConfig(logging_config)
elif isinstance(logging_config, (bytes, str, pathlib.Path)):
import toml
with open(logging_config, "r") as f:
logging_config = toml.load(f)
logging.config.dictConfig(logging_config)
else:
logging.basicConfig(level=logging.WARNING)
if app.debug:
logging.getLogger("snikket_web").setLevel(logging.DEBUG)
infra.babel.init_app(app)
infra.client.init_app(app)
infra.init_templating(app)
from .main import bp as main_bp
from .user import bp as user_bp
from .admin import bp as admin_bp
from .invite import bp as invite_bp
app.register_blueprint(main_bp)
app.register_blueprint(user_bp, url_prefix="/user")
app.register_blueprint(admin_bp, url_prefix="/admin")
app.register_blueprint(invite_bp, url_prefix="/invite")
return app