kemono2/src/server.py
2025-04-02 16:32:47 +02:00

302 lines
11 KiB
Python

import json
import os
import pathlib
import jinja2
from flask import Flask, g, make_response, render_template, request, send_from_directory, session, jsonify
from flask.json.provider import JSONProvider
from src.config import Configuration
from src.lib.api import create_api_v2_client_error_response, TDAPIError
app = Flask(
__name__,
static_folder=Configuration().webserver["static_folder"],
template_folder=Configuration().webserver["template_folder"],
)
if jinja_bytecode_cache_path := Configuration().webserver["jinja_bytecode_cache_path"]:
pathlib.Path(jinja_bytecode_cache_path).mkdir(parents=True, exist_ok=True)
app.jinja_env.bytecode_cache = jinja2.FileSystemBytecodeCache(jinja_bytecode_cache_path)
if Configuration().open_telemetry_endpoint:
from src.internals.tracing.tracing import open_telemetry_init
open_telemetry_init(app, Configuration().open_telemetry_endpoint)
import datetime
import logging
import re
from datetime import timedelta
from os import getenv, listdir
from os.path import exists, join, splitext
from typing import TypedDict
from urllib.parse import urljoin, quote_plus
import dateutil.parser
import humanize
import orjson
from src.lib.account import is_logged_in, load_account
from src.lib.notification import count_new_notifications
from src.lib.post import get_fileserver_for_value
from src.pages.api import api_bp
from src.types.account import Account
from src.utils.utils import (
freesites,
paysites,
render_page_data,
sanitize_html,
url_is_for_non_logged_file_extension,
parse_int,
)
app.url_map.strict_slashes = False
app.register_blueprint(api_bp)
app.config.update(
dict(
ENABLE_PASSWORD_VALIDATOR=True,
ENABLE_LOGIN_RATE_LIMITING=True,
SESSION_REFRESH_EACH_REQUEST=False,
SESSION_COOKIE_SAMESITE="Lax",
SECRET_KEY=Configuration().webserver["secret"],
cache_TYPE="null" if Configuration().development_mode else "simple",
CACHE_DEFAULT_TIMEOUT=None if Configuration().development_mode else 60,
SEND_FILE_MAX_AGE_DEFAULT=0,
TEMPLATES_AUTO_RELOAD=True if Configuration().development_mode else False,
)
)
def file_url(file: TypedDict("FILE", {"hash": str, "ext": str})) -> str:
file_hash = file["hash"]
ext = file["ext"]
fn = f"/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}.{ext}"
fs = get_fileserver_for_value(fn)
return fs + fn
class ORJSONProvider(JSONProvider):
def __init__(self, *args, **kwargs):
self.options = kwargs
super().__init__(*args, **kwargs)
def loads(self, s, **kwargs):
if "object_hook" in kwargs:
return json.loads(s, **kwargs)
return orjson.loads(s)
def dumps(self, obj, **kwargs):
return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS).decode("utf-8")
app.json = ORJSONProvider(app)
def simple_datetime(obj: str | datetime.datetime) -> str:
if isinstance(obj, str):
obj = dateutil.parser.parse(obj)
return obj.strftime("%Y-%m-%d %H:%M:%S")
app.jinja_options = dict(trim_blocks=True, lstrip_blocks=True)
app.jinja_env.globals.update(is_logged_in=is_logged_in)
app.jinja_env.globals.update(render_page_data=render_page_data)
app.jinja_env.filters["relative_date"] = lambda val: humanize.naturaltime(val)
app.jinja_env.filters["simple_date"] = lambda dt: dt.strftime("%Y-%m-%d") # maybe change
app.jinja_env.filters["simple_datetime"] = simple_datetime
app.jinja_env.filters["regex_match"] = lambda val, rgx: re.search(rgx, val)
app.jinja_env.filters["regex_find"] = lambda val, rgx: re.findall(rgx, val)
app.jinja_env.filters["sanitize_html"] = sanitize_html
app.jinja_env.filters["file_url"] = file_url # todo use this instead of "{{ fancard.server or '' }}{{ fancard.path }}"
app.jinja_env.filters["quote_plus"] = lambda u: quote_plus(u or "")
app.jinja_env.filters["debug"] = lambda u: print(u) or u
app.jinja_env.filters["parse_int"] = lambda val: parse_int(val)
if Configuration().webserver["logging"]:
logging.basicConfig(
filename="../kemono.log",
level=logging.getLevelName(Configuration().webserver["logging"]),
)
logging.getLogger("PIL").setLevel(logging.INFO)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
if Configuration().sentry_dsn:
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.redis import RedisIntegration
sentry_sdk.utils.MAX_STRING_LENGTH = 2048
sentry_sdk.serializer.MAX_EVENT_BYTES = 10**7
sentry_sdk.serializer.MAX_DATABAG_DEPTH = 8
sentry_sdk.serializer.MAX_DATABAG_BREADTH = 20
sentry_sdk.init(
integrations=[FlaskIntegration(), RedisIntegration()],
dsn=Configuration().sentry_dsn,
release=os.getenv("GIT_COMMIT_HASH") or "NOT_FOUND",
max_request_body_size="always",
max_value_length=1024 * 4,
attach_stacktrace=True,
max_breadcrumbs=1000,
send_default_pii=True,
)
import src.internals.cache.redis as redis
import src.internals.database.database as database
database.init()
redis.init()
if exists(nondynamic_folder := join(Configuration().webserver["template_folder"].replace("..", "."), "nondynamic")):
def nondynamic_view(page_file):
def nondynamic_view_func():
if ".html" in page_file:
return make_response(
render_template(
f"nondynamic/{page_file}",
props=dict(),
base=request.args.to_dict(),
),
200,
)
else:
return send_from_directory(nondynamic_folder, page_file)
return nondynamic_view_func
for page in listdir(nondynamic_folder):
app.get(f"/{splitext(page)[0]}", endpoint=f"get_{splitext(page)[0]}")(nondynamic_view(page))
@app.before_request
def do_init_stuff():
app.permanent_session_lifetime = timedelta(days=3650)
g.page_data = {}
g.request_start_time = datetime.datetime.now()
g.freesites = freesites
g.artists_or_creators = Configuration().webserver["ui"]["config"]["artists_or_creators"]
g.paysite_list = Configuration().webserver["ui"]["config"]["paysite_list"]
g.paysites = paysites
g.origin = Configuration().webserver["site"]
g.custom_links = Configuration().webserver["ui"]["sidebar_items"]
g.custom_footer = Configuration().webserver["ui"]["footer_items"]
# Matomo.
g.matomo_enabled = Configuration().webserver["ui"]["matomo"]["enabled"]
g.matomo_plain_code = Configuration().webserver["ui"]["matomo"]["plain_code"]
g.matomo_domain = Configuration().webserver["ui"]["matomo"]["tracking_domain"]
g.matomo_code = Configuration().webserver["ui"]["matomo"]["tracking_code"]
g.matomo_site_id = Configuration().webserver["ui"]["matomo"]["site_id"]
# navbar
g.disable_filehaus = Configuration().webserver["ui"]["sidebar"]["disable_filehaus"]
g.disable_faq = Configuration().webserver["ui"]["sidebar"]["disable_faq"]
g.disable_dms = Configuration().webserver["ui"]["sidebar"]["disable_dms"]
# Ads.
g.header_ad = Configuration().webserver["ui"]["ads"]["header"]
g.middle_ad = Configuration().webserver["ui"]["ads"]["middle"]
g.footer_ad = Configuration().webserver["ui"]["ads"]["footer"]
g.slider_ad = Configuration().webserver["ui"]["ads"]["slider"]
g.video_ad = Configuration().webserver["ui"]["ads"]["video"]
# Banners.
g.banner_global = Configuration().webserver["ui"]["banner"]["global"]
g.banner_welcome = Configuration().webserver["ui"]["banner"]["welcome"]
# Branding path prepend
g.icons_prepend = Configuration().webserver["ui"]["files_url_prepend"]["icons_base_url"]
g.banners_prepend = Configuration().webserver["ui"]["files_url_prepend"]["banners_base_url"]
g.thumbnails_prepend = Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"]
g.mascot_path = Configuration().webserver["ui"]["home"]["mascot_path"]
g.logo_path = Configuration().webserver["ui"]["home"]["logo_path"]
g.welcome_credits = Configuration().webserver["ui"]["home"]["welcome_credits"]
g.home_background_image = Configuration().webserver["ui"]["home"]["home_background_image"]
g.announcements = Configuration().webserver["ui"]["home"]["announcements"]
g.site_name = Configuration().webserver["ui"]["home"]["site_name"]
g.canonical_url = urljoin(Configuration().webserver["site"], request.path)
session.permanent = True
session.modified = False
if account := load_account():
g.account = account
if Configuration().enable_notifications:
g.new_notifications_count = count_new_notifications(g.account.id)
@app.after_request
def do_finish_stuff(response):
if not url_is_for_non_logged_file_extension(request.path):
start_time = g.request_start_time
end_time = datetime.datetime.now()
elapsed = end_time - start_time
app.logger.debug(
f"[{end_time.strftime("%Y-%m-%d %X")}] "
f"Completed {request.method} request to {request.url} "
f"in {elapsed.microseconds / 1000}ms"
)
response.autocorrect_location_header = False
return response
# adding the handlers there because
# flask doesn't allow handling 404/405 in blueprints
# https://flask.palletsprojects.com/en/2.3.x/errorhandling/#blueprint-error-handlers
@app.errorhandler(404)
def route_not_found(error):
if request.path.startswith('/api/v1'):
return jsonify(error="Not Found"), 404
if request.path.startswith('/api/v2'):
error = TDAPIError(type="http_error", message="Not Found")
return create_api_v2_client_error_response(error, 404)
return error, 404
@app.errorhandler(405)
def method_not_allowed(error):
if request.path.startswith('/api/v1'):
return jsonify(error="Method Not Allowed"), 405
if request.path.startswith('/api/v2'):
error = TDAPIError(type="http_error", message="Method Not Allowed")
return create_api_v2_client_error_response(error, 405)
return error, 405
@app.errorhandler(413)
def upload_exceeded(error):
props = {"redirect": request.headers.get("Referer") if request.headers.get("Referer") else "/"}
limit = int(getenv("REQUESTS_IMAGES")) if getenv("REQUESTS_IMAGES") else 1048576
props["message"] = f"Submitted file exceeds the upload limit. {limit / 1024 / 1024} MB for requests images."
return render_template("error.html", props=props), 413
@app.teardown_appcontext
def close(e):
# removing account just in case
g.pop("account", None)
cursor = g.pop("cursor", None)
if cursor is not None:
cursor.close()
connection = g.pop("connection", None)
if connection is not None:
try:
pool = database.get_pool()
if not connection.autocommit:
connection.commit()
pool.putconn(connection)
except Exception:
pass