From 487f1b1200f51870ae3d7ad22c4c0f594d4e6a95 Mon Sep 17 00:00:00 2001 From: Taggie Date: Mon, 19 Jan 2026 01:24:23 -0500 Subject: [PATCH 01/41] code overhaul with docs, hooks, config, custom error types, etc. you might get more details in the pr fuck if i know --- .pre-commit-config.yaml | 15 ++ __init__.py | 80 +--------- pyproject.toml | 9 +- ruff.toml | 37 +++++ src/app/__init__.py | 2 + src/app/app.py | 37 +++++ src/app/middleware.py | 42 +++++ src/app/static.py | 19 +++ src/auth/__init__.py | 2 + src/auth/permissions.py | 2 +- src/auth/user.py | 5 +- src/blueprints/__init__.py | 18 ++- src/blueprints/error_handler/__init__.py | 17 +++ src/blueprints/github/__init__.py | 82 +++++++--- src/blueprints/index/__init__.py | 1 + src/blueprints/term/__init__.py | 32 ++-- src/config/__init__.py | 4 +- src/database/__init__.py | 2 + src/database/database.py | 8 +- src/errors/__init__.py | 12 ++ src/errors/_foxterm_error.py | 6 + src/errors/_permission_error.py | 8 + src/errors/_request_error.py | 8 + src/errors/_server_error.py | 8 + src/errors/_unauthorized_error.py | 8 + uv.lock | 185 ++++++++++++++++++++++- 26 files changed, 522 insertions(+), 127 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 ruff.toml create mode 100644 src/app/__init__.py create mode 100644 src/app/app.py create mode 100644 src/app/middleware.py create mode 100644 src/app/static.py create mode 100644 src/blueprints/error_handler/__init__.py create mode 100644 src/errors/__init__.py create mode 100644 src/errors/_foxterm_error.py create mode 100644 src/errors/_permission_error.py create mode 100644 src/errors/_request_error.py create mode 100644 src/errors/_server_error.py create mode 100644 src/errors/_unauthorized_error.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..01a4999 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.9.26 + hooks: + - id: uv-lock + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.14.13 + hooks: + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format \ No newline at end of file diff --git a/__init__.py b/__init__.py index 00cc374..f8ef870 100644 --- a/__init__.py +++ b/__init__.py @@ -1,82 +1,11 @@ -import re -from quart import Quart, request, render_template -from quart_cors import cors -from quart_auth import QuartAuth +from __future__ import annotations from logging import getLogger from src.about import is_dev -from src.auth import User - - -class ASGIMiddleware: - # this is my baby. she is deformed. - # be nice to my baby. - def __init__(self, app) -> None: - self.app = app - - async def inner(self, scope): - if scope["type"] != "http": - return scope - headers = scope.get("headers", []) - host = (list(filter(lambda e: e[0] == b"host", headers)) or [None])[0] - if host is None or len(host) != 2: - return scope - if host[1].decode().startswith("dev."): - index = headers.index(host) - host = (host[0], host[1].decode().replace("dev.", "").encode()) - headers[index] = host - scope["headers"] = headers - return scope - - async def __call__(self, scope, recv, send): - scope = await self.inner(scope) - await self.app(scope, recv, send) - - -app = Quart(__name__) -app.config.from_prefixed_env("QUART") -app.asgi_app = ASGIMiddleware(app.asgi_app) -config_mode = "Production" - -if app.config["DEBUG"]: - config_mode = "Development" - app.logger.info("Loading Development configuration...") -else: - app = cors(app, allow_origin=re.compile("https://*.yip.cat*")) -app.config.from_object(f"src.config.{config_mode}") - -auth_manager = QuartAuth(app) -auth_manager.user_class = User - -from src.blueprints import ( # noqa: E402 - index_blueprint, - term_blueprint, - github_blueprint, -) - -for blueprint in [index_blueprint, term_blueprint, github_blueprint]: - app.register_blueprint(blueprint) - - -async def static(location=None, filename=None): - if filename is None: - return - from quart import url_for - - if location is not None: - return url_for(f"{location}.static", filename=filename) - return url_for("static", filename=filename) - - -app.jinja_env.globals.update(static=static) -auth_manager.init_app(app) - - -@app.errorhandler(404) -async def http_404(e): - return await render_template("404.jinja", page=request.host), 404 +from src.app import create_app +app = create_app(__name__) if __name__ == "__main__": if app.config["DEBUG"]: @@ -84,4 +13,5 @@ async def http_404(e): else: getLogger("hypercorn.access").disabled = True getLogger("hypercorn.error").disabled = True - app.run(host="0.0.0.0", port=80 if not is_dev else 1080) + app.run(host="0.0.0.0", port=80 if not is_dev else 1080) # noqa: S104 + # TODO: fix S104 above and add port env var diff --git a/pyproject.toml b/pyproject.toml index 153bb82..bf07194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,21 @@ [project] name = "foxes-new" -version = "0.6.1" +version = "0.7.0" description = "taggie waggie's website" readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiohttp>=3.13.2", "aiosqlite>=0.21.0", + "anyio>=4.12.1", "quart>=0.20.0", "quart-auth>=0.11.0", "quart-cors>=0.8.0", "sqlalchemy[asyncio]>=2.0.45", ] + +[dependency-groups] +dev = [ + "pre-commit>=4.5.1", + "ruff>=0.14.13", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6020f7e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,37 @@ +line-length = 88 +indent-width = 4 + +[lint] +select = [ + "E", "W", "F", + "I", + "UP", + "C4", + "ICN", + "RET", + "SIM", + "TC", + "N", + "ERA", + "ANN", + "ASYNC", + "S", + "EM", + "INP", + "T20", + "ARG", + "PERF", + "FURB" +] +ignore = [ + "ANN201", "E501" # fuck you i make my lines as long as i want +] + +[lint.per-file-ignores] +"__init__.py" = ["E402"] + +[format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" \ No newline at end of file diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..52397d1 --- /dev/null +++ b/src/app/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["create_app"] +from .app import create_app diff --git a/src/app/app.py b/src/app/app.py new file mode 100644 index 0000000..935584e --- /dev/null +++ b/src/app/app.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import re + +from quart import Quart +from quart_auth import QuartAuth +from quart_cors import cors + +from src.auth import User +from src.blueprints import blueprints + +from .middleware import ASGIMiddleware +from .static import static + + +def create_app(import_name: str) -> Quart: + app = Quart(import_name) + app.config.from_prefixed_env("QUART") + app.asgi_app = ASGIMiddleware(app.asgi_app) + if app.config["DEBUG"]: + config_mode = "Development" + app.logger.info("Loading Development configuration...") + else: + config_mode = "Production" + app = cors(app, allow_origin=re.compile("https://*.yip.cat*")) + app.config.from_object(f"src.config.{config_mode}") + + auth_manager = QuartAuth(app) + auth_manager.user_class = User + + for blueprint in blueprints: + app.register_blueprint(blueprint) + + app.jinja_env.globals.update(static=static) + auth_manager.init_app(app) + + return app diff --git a/src/app/middleware.py b/src/app/middleware.py new file mode 100644 index 0000000..c88dff9 --- /dev/null +++ b/src/app/middleware.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hypercorn.typing import ( + ASGIFramework, + ASGIReceiveCallable, + ASGISendCallable, + HTTPScope, + ) + +__all__ = ["ASGIMiddleware"] + + +class ASGIMiddleware: + """Custom middleware to remove "dev." subdomain from HTTP requests""" + + # this is my baby. she is deformed. + # be nice to my baby. + def __init__(self, app: ASGIFramework) -> None: + self.app = app + + async def inner(self, scope: HTTPScope): + if scope["type"] != "http": + return scope + headers = scope.get("headers", []) + host = (list(filter(lambda e: e[0] == b"host", headers)) or [None])[0] + if host is None or len(host) != 2: + return scope + if host[1].decode().startswith("dev."): + index = headers.index(host) + host = (host[0], host[1].decode().replace("dev.", "").encode()) + headers[index] = host + scope["headers"] = headers + return scope + + async def __call__( + self, scope: HTTPScope, recv: ASGIReceiveCallable, send: ASGISendCallable + ) -> None: + scope = await self.inner(scope) + await self.app(scope, recv, send) diff --git a/src/app/static.py b/src/app/static.py new file mode 100644 index 0000000..7a5f14c --- /dev/null +++ b/src/app/static.py @@ -0,0 +1,19 @@ +from quart import url_for + + +async def static(location: str = None, filename: str = None) -> str | None: + """Custom static implementation that accepts routes for other static locations + + Args: + location (str, optional): Endpoint/folder to search in. Default: None (current template's folder) + filename (str, optional): Default: None (causes this function to return None) + + Returns: + str: URL to a given file, if it exists + """ + if filename is None: + return None + + if location is not None: + return url_for(f"{location}.static", filename=filename) + return url_for("static", filename=filename) diff --git a/src/auth/__init__.py b/src/auth/__init__.py index f6ad543..048c283 100644 --- a/src/auth/__init__.py +++ b/src/auth/__init__.py @@ -1,2 +1,4 @@ +__all__ = ["Permissions", "User"] + from .permissions import Permissions from .user import UserClass as User diff --git a/src/auth/permissions.py b/src/auth/permissions.py index 3ffcc87..d668090 100644 --- a/src/auth/permissions.py +++ b/src/auth/permissions.py @@ -6,7 +6,7 @@ class Permissions: UPLOAD_IMAGES = 16 @staticmethod - def sort(): + def sort() -> list: _current = [ value for name, value in vars(Permissions).items() diff --git a/src/auth/user.py b/src/auth/user.py index 40f6dfc..e021c04 100644 --- a/src/auth/user.py +++ b/src/auth/user.py @@ -1,17 +1,18 @@ from quart_auth import AuthUser from sqlalchemy import update + from src.database import Session, User class UserClass(AuthUser): - def __init__(self, auth_id): + def __init__(self, auth_id: str | None) -> None: super().__init__(auth_id) self._resolved = False self._id = None self._login = None self._permissions = None - async def _resolve(self): + async def _resolve(self) -> None: if self._resolved: return if not await self.is_authenticated: diff --git a/src/blueprints/__init__.py b/src/blueprints/__init__.py index cb0d0ac..c5f7a0d 100644 --- a/src/blueprints/__init__.py +++ b/src/blueprints/__init__.py @@ -1,3 +1,15 @@ -from .github import blueprint as github_blueprint -from .index import blueprint as index_blueprint -from .term import blueprint as term_blueprint +__all__ = ["blueprints"] + +import importlib +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) + +blueprints = [] + +for loader, module_name, is_package in pkgutil.walk_packages(__path__): + full_module_name = f"{__name__}.{module_name}" + module = importlib.import_module(full_module_name) + + if hasattr(module, "blueprint"): + blueprints.append(module.blueprint) diff --git a/src/blueprints/error_handler/__init__.py b/src/blueprints/error_handler/__init__.py new file mode 100644 index 0000000..6795903 --- /dev/null +++ b/src/blueprints/error_handler/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from quart import Blueprint, render_template, request + +from src.errors import FoxtermError + +blueprint = Blueprint("error_handler", __name__) + + +@blueprint.app_errorhandler(404) +async def http_404_handler(e: Exception): # noqa: ARG001 + return await render_template("404.jinja", page=request.host), 404 + + +@blueprint.app_errorhandler(FoxtermError) +async def foxterm_error_handler(e: FoxtermError): + return {"success": False, "error": e.error}, e.status_code diff --git a/src/blueprints/github/__init__.py b/src/blueprints/github/__init__.py index be701b9..25dac7f 100644 --- a/src/blueprints/github/__init__.py +++ b/src/blueprints/github/__init__.py @@ -1,17 +1,19 @@ +from os import environ + import aiohttp from quart import ( Blueprint, + make_response, + redirect, render_template, request, session, - redirect, url_for, - make_response, ) -from quart_auth import AuthUser, login_user, logout_user, login_required, current_user -from os import environ +from quart_auth import AuthUser, current_user, login_required, login_user, logout_user from sqlalchemy import update +import src.errors as errors from src.database import Session, User blueprint = Blueprint( @@ -45,21 +47,28 @@ async def logout(): @blueprint.route("/github/logout-term") async def logout_term(): if not await current_user.is_authenticated: - return {"success": False}, 401 + msg = "You are not logged in!" + raise errors.UnauthorizedError(msg) logout_user() return {"success": True} @blueprint.route("/github/callback", methods=["GET"]) async def callback(): - args = request.args next = session.get("next") - request_token = args.get("code") + request_token = request.args.get("code") - access_token = await get_access_token(request_token) - user_data = await get_user_data(access_token) - if user_data.get("id") is not None: - db_session = Session() + if request_token is None: + msg = "Bad request. Expected arguments: 'request_token'" + raise errors.RequestError(msg) + + try: + access_token = await get_access_token(request_token) + user_data = await get_user_data(access_token) + except RuntimeError as e: + raise errors.ServerError(e) + + with Session() as db_session: if db_session.query(User).filter(User.id == user_data["id"]).count() == 0: db_session.add(User(id=user_data["id"], login=user_data["login"])) elif ( @@ -72,25 +81,54 @@ async def callback(): ) db_session.commit() db_session.close() + login_user(AuthUser(user_data["id"])) - if next: + + if next is not None: return redirect(next) + response = await make_response(redirect(url_for("github.loggedin"))) response.set_cookie("logged_in", str(await current_user.is_authenticated)) return response -async def get_access_token(request_token): +async def get_access_token(request_token: str) -> str: url = f"https://github.com/login/oauth/access_token?client_id={environ.get('GITHUB_ID')}&client_secret={environ.get('GITHUB_SECRET')}&code={request_token}" headers = {"accept": "application/json"} - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers) as response: - return (await response.json())["access_token"] - - -async def get_user_data(access_token): + async with ( + aiohttp.ClientSession() as session, + await session.post(url, headers=headers) as response, + ): + if not response.ok: + msg = f"Unable to get access token from GitHub OAuth login flow! Received code HTTP {response.status}." + raise RuntimeError(msg) + try: + data: dict = await response.json() + except aiohttp.ContentTypeError: + msg = "GitHub OAuth login flow did not return a JSON object, but still returned HTTP 200. If you see this, you're fucked." + raise RuntimeError(msg) + if data.get("access_token") is None: + msg = "GitHub OAuth login flow did not return an access_token, but still returned HTTP 200. What. the fuck?" + raise RuntimeError(msg) + + return data["access_token"] + + +async def get_user_data(access_token: str) -> dict: url = "https://api.github.com/user" headers = {"Authorization": f"token {access_token}"} - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers) as response: - return await response.json() + async with ( + aiohttp.ClientSession() as session, + await session.get(url, headers=headers) as response, + ): + if not response.ok: + msg = f"Unable to get user data from the GitHub API! Received code HTTP {response.status}." + raise RuntimeError(msg) + try: + data: dict = await response.json() + except aiohttp.ContentTypeError: + msg = "GitHub API did not return a JSON object, but still returned HTTP 200. What the hell happened." + raise RuntimeError(msg) + if data.get("id") is None: + msg = "GitHub API did not return proper user data (missing 'id'), but still returned HTTP 200. Why. What." + raise RuntimeError(msg) diff --git a/src/blueprints/index/__init__.py b/src/blueprints/index/__init__.py index d85a131..f0df2c2 100644 --- a/src/blueprints/index/__init__.py +++ b/src/blueprints/index/__init__.py @@ -1,4 +1,5 @@ from quart import Blueprint, render_template, send_from_directory + from src.about import is_dev blueprint = Blueprint( diff --git a/src/blueprints/term/__init__.py b/src/blueprints/term/__init__.py index df6c4ee..f163bea 100644 --- a/src/blueprints/term/__init__.py +++ b/src/blueprints/term/__init__.py @@ -1,8 +1,11 @@ -from sqlalchemy import select, func +import os + +import anyio from quart import Blueprint, current_app, request from quart_auth import current_user -import os +from sqlalchemy import func, select +import src.errors as errors from src.about import is_dev, version from src.auth import Permissions from src.database import Session, User @@ -49,7 +52,7 @@ async def ls(): return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} -def _replace_data(line: str): +def _replace_data(line: str) -> str: if "<{version}>" in line: line = line.replace("<{version}>", version) return line @@ -65,14 +68,14 @@ async def cat(): return "", 403 home = "static/filesystem" cwd = cwd.replace("~", "").rstrip("/") - if path.lower() in links.keys(): + if path.lower() in links: filepath = links[path.lower()] else: filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" if not os.path.exists(filepath): return "", 404 - with open(filepath, "r", encoding="utf-8") as fp: - lines = fp.readlines() + async with await anyio.open_file(filepath, encoding="utf-8") as fp: + lines = await fp.readlines() for line in list(filter(lambda e: "<{" in e and "}>" in e, lines)): index = lines.index(line) lines[index] = _replace_data(line) @@ -88,7 +91,7 @@ async def cd(): return "", 400 if len(path) == 0: return "", 400 - elif path == "~": + if path == "~": filepath = f"{home}" elif path[0] == "/": filepath = f"{home}{path}" @@ -104,8 +107,8 @@ async def cd(): async def login_text(): commit_hash = "" if os.path.exists(".git/refs/heads/dev"): - with open(".git/refs/heads/dev") as fp: - commit_hash = fp.readline().strip("\n") + async with await anyio.open_file(".git/refs/heads/dev") as fp: + commit_hash = (await fp.readline()).strip("\n") return ( f"foxterm {version}{' dev' if is_dev else ''}" @@ -125,10 +128,7 @@ async def view_users(): if not await current_user.has_permission( Permissions.ADMIN | Permissions.VIEW_USERS ): - return { - "success": False, - "error": "You do not have permission to run this command!", - }, 403 + raise errors.PermissionError() return {"success": True} @@ -138,10 +138,8 @@ async def users_source(): if not await current_user.has_permission( Permissions.ADMIN | Permissions.VIEW_USERS ): - return { - "success": False, - "error": "You do not have permission to run this command!", - }, 403 + raise errors.PermissionError() + draw = int(request.args.get("draw", 0)) start = int(request.args.get("start", 0)) limit = int(request.args.get("length", 10)) diff --git a/src/config/__init__.py b/src/config/__init__.py index 6f6a35f..ccb12c1 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -1 +1,3 @@ -from .config import Production, Development +__all__ = ["Development", "Production"] + +from .config import Development, Production diff --git a/src/database/__init__.py b/src/database/__init__.py index f9cd194..92c4127 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -1 +1,3 @@ +__all__ = ["Session", "User"] + from .database import Session, User diff --git a/src/database/database.py b/src/database/database.py index 265a307..f9cbde0 100644 --- a/src/database/database.py +++ b/src/database/database.py @@ -1,8 +1,8 @@ +from collections.abc import Iterator from os import environ -from sqlalchemy import Integer, String, create_engine -from sqlalchemy.orm import DeclarativeBase, sessionmaker -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, create_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker engine = create_engine(environ.get("DATABASE_PATH", "sqlite:///")) @@ -18,7 +18,7 @@ class User(Base): login: Mapped[str] = mapped_column(String(39)) permissions: Mapped[int] = mapped_column(default=1) - def __iter__(self): + def __iter__(self) -> Iterator: return iter([self.id, self.login, self.permissions]) diff --git a/src/errors/__init__.py b/src/errors/__init__.py new file mode 100644 index 0000000..e91c2f9 --- /dev/null +++ b/src/errors/__init__.py @@ -0,0 +1,12 @@ +__all__ = [ + "FoxtermError", + "PermissionError", + "RequestError", + "ServerError", + "UnauthorizedError", +] +from ._foxterm_error import FoxtermError +from ._permission_error import PermissionError +from ._request_error import RequestError +from ._server_error import ServerError +from ._unauthorized_error import UnauthorizedError diff --git a/src/errors/_foxterm_error.py b/src/errors/_foxterm_error.py new file mode 100644 index 0000000..9c2dc1a --- /dev/null +++ b/src/errors/_foxterm_error.py @@ -0,0 +1,6 @@ +class FoxtermError(Exception): + def __init__(self, status_code: int, error: str | Exception) -> None: + if isinstance(error, Exception): + error = str(error) + self.status_code = status_code + self.error = error diff --git a/src/errors/_permission_error.py b/src/errors/_permission_error.py new file mode 100644 index 0000000..dbdd040 --- /dev/null +++ b/src/errors/_permission_error.py @@ -0,0 +1,8 @@ +from ._foxterm_error import FoxtermError + + +class PermissionError(FoxtermError): + def __init__(self, error_message: str | None = None) -> None: + if error_message is None: + error_message = "You do not have permission to run this command." + super().__init__(403, error_message) diff --git a/src/errors/_request_error.py b/src/errors/_request_error.py new file mode 100644 index 0000000..ef5633e --- /dev/null +++ b/src/errors/_request_error.py @@ -0,0 +1,8 @@ +from ._foxterm_error import FoxtermError + + +class RequestError(FoxtermError): + def __init__(self, error_message: str | None = None) -> None: + if error_message is None: + error_message = "Malformed request." + super().__init__(400, error_message) diff --git a/src/errors/_server_error.py b/src/errors/_server_error.py new file mode 100644 index 0000000..ffefc7d --- /dev/null +++ b/src/errors/_server_error.py @@ -0,0 +1,8 @@ +from ._foxterm_error import FoxtermError + + +class ServerError(FoxtermError): + def __init__(self, error_message: str | None = None) -> None: + if error_message is None: + error_message = "An internal server error has occurred." + super().__init__(500, error_message) diff --git a/src/errors/_unauthorized_error.py b/src/errors/_unauthorized_error.py new file mode 100644 index 0000000..b7dc920 --- /dev/null +++ b/src/errors/_unauthorized_error.py @@ -0,0 +1,8 @@ +from ._foxterm_error import FoxtermError + + +class UnauthorizedError(FoxtermError): + def __init__(self, error_message: str | Exception | None = None) -> None: + if error_message is None: + error_message = "You are not authorized to run this command." + super().__init__(401, error_message) diff --git a/uv.lock b/uv.lock index 6a2abd2..0befb0d 100644 --- a/uv.lock +++ b/uv.lock @@ -130,6 +130,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792 }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -148,6 +161,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + [[package]] name = "click" version = "8.2.1" @@ -169,6 +191,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701 }, +] + [[package]] name = "flask" version = "3.1.2" @@ -188,27 +228,41 @@ wheels = [ [[package]] name = "foxes-new" -version = "0.6.1" +version = "0.7.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "aiosqlite" }, + { name = "anyio" }, { name = "quart" }, { name = "quart-auth" }, { name = "quart-cors" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "anyio", specifier = ">=4.12.1" }, { name = "quart", specifier = ">=0.20.0" }, { name = "quart-auth", specifier = ">=0.11.0" }, { name = "quart-cors", specifier = ">=0.8.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "ruff", specifier = ">=0.14.13" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -392,6 +446,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, ] +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202 }, +] + [[package]] name = "idna" version = "3.11" @@ -559,6 +622,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317 }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + [[package]] name = "priority" version = "2.0.0" @@ -652,6 +749,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + [[package]] name = "quart" version = "0.20.0" @@ -696,6 +839,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/31/da390a5a10674481dea2909178973de81fa3a246c0eedcc0e1e4114f52f8/quart_cors-0.8.0-py3-none-any.whl", hash = "sha256:62dc811768e2e1704d2b99d5880e3eb26fc776832305a19ea53db66f63837767", size = 8698 }, ] +[[package]] +name = "ruff" +version = "0.14.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418 }, + { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344 }, + { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720 }, + { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493 }, + { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174 }, + { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909 }, + { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215 }, + { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067 }, + { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916 }, + { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207 }, + { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686 }, + { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837 }, + { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867 }, + { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528 }, + { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242 }, + { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024 }, + { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887 }, + { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224 }, +] + [[package]] name = "sqlalchemy" version = "2.0.45" @@ -745,6 +914,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258 }, +] + [[package]] name = "werkzeug" version = "3.1.3" From 85e264db0e54e0bc4aa4b01b10a079d42d9a11b7 Mon Sep 17 00:00:00 2001 From: Taggie Date: Mon, 19 Jan 2026 01:25:51 -0500 Subject: [PATCH 02/41] chore: bump version FUCKKKKKKKKKKKKKKKK I FORGOT TO CHANGE ITTTT --- src/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/about.py b/src/about.py index f58cd02..95d2728 100644 --- a/src/about.py +++ b/src/about.py @@ -1,6 +1,6 @@ import os -version = "0.6.4" +version = "0.7.0" if os.path.exists(".git/HEAD"): with open(".git/HEAD") as fp: From 15f03479b39b1f53df7d5bd1392e9b626e7e2a82 Mon Sep 17 00:00:00 2001 From: Taggie Date: Mon, 2 Feb 2026 19:26:44 -0500 Subject: [PATCH 03/41] add responses/ folder, move blueprints into dedicated blueprint.py file --- .vscode/settings.json | 4 + pyproject.toml | 1 + src/app/app.py | 2 + src/blueprints/error_handler/__init__.py | 19 +- src/blueprints/error_handler/blueprint.py | 18 ++ src/blueprints/github/__init__.py | 136 +-------------- src/blueprints/github/blueprint.py | 135 ++++++++++++++ src/blueprints/index/__init__.py | 31 +--- src/blueprints/index/blueprint.py | 29 ++++ src/blueprints/term/__init__.py | 203 +--------------------- src/blueprints/term/blueprint.py | 199 +++++++++++++++++++++ src/responses/__init__.py | 4 + src/responses/_base_response.py | 6 + src/responses/_error_response.py | 9 + src/responses/_success_response.py | 8 + src/responses/_url_response.py | 8 + uv.lock | 136 +++++++++++++++ 17 files changed, 567 insertions(+), 381 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/blueprints/error_handler/blueprint.py create mode 100644 src/blueprints/github/blueprint.py create mode 100644 src/blueprints/index/blueprint.py create mode 100644 src/blueprints/term/blueprint.py create mode 100644 src/responses/__init__.py create mode 100644 src/responses/_base_response.py create mode 100644 src/responses/_error_response.py create mode 100644 src/responses/_success_response.py create mode 100644 src/responses/_url_response.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1d5a1a0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bf07194..07a7109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "quart>=0.20.0", "quart-auth>=0.11.0", "quart-cors>=0.8.0", + "quart-schema[pydantic]>=0.23.0", "sqlalchemy[asyncio]>=2.0.45", ] diff --git a/src/app/app.py b/src/app/app.py index 935584e..c7996f6 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -5,6 +5,7 @@ from quart import Quart from quart_auth import QuartAuth from quart_cors import cors +from quart_schema import QuartSchema from src.auth import User from src.blueprints import blueprints @@ -15,6 +16,7 @@ def create_app(import_name: str) -> Quart: app = Quart(import_name) + QuartSchema(app) app.config.from_prefixed_env("QUART") app.asgi_app = ASGIMiddleware(app.asgi_app) if app.config["DEBUG"]: diff --git a/src/blueprints/error_handler/__init__.py b/src/blueprints/error_handler/__init__.py index 6795903..5f142e3 100644 --- a/src/blueprints/error_handler/__init__.py +++ b/src/blueprints/error_handler/__init__.py @@ -1,17 +1,2 @@ -from __future__ import annotations - -from quart import Blueprint, render_template, request - -from src.errors import FoxtermError - -blueprint = Blueprint("error_handler", __name__) - - -@blueprint.app_errorhandler(404) -async def http_404_handler(e: Exception): # noqa: ARG001 - return await render_template("404.jinja", page=request.host), 404 - - -@blueprint.app_errorhandler(FoxtermError) -async def foxterm_error_handler(e: FoxtermError): - return {"success": False, "error": e.error}, e.status_code +__all__ = ["blueprint"] +from src.blueprints.error_handler.blueprint import blueprint diff --git a/src/blueprints/error_handler/blueprint.py b/src/blueprints/error_handler/blueprint.py new file mode 100644 index 0000000..2394e75 --- /dev/null +++ b/src/blueprints/error_handler/blueprint.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from quart import Blueprint, render_template, request + +from src.errors import FoxtermError +from src.responses import ErrorResponse + +blueprint = Blueprint("error_handler", __name__) + + +@blueprint.app_errorhandler(404) +async def http_404_handler(e: Exception): # noqa: ARG001 + return await render_template("404.jinja", page=request.host), 404 + + +@blueprint.app_errorhandler(FoxtermError) +async def foxterm_error_handler(e: FoxtermError): + return ErrorResponse(error=e.error), e.status_code diff --git a/src/blueprints/github/__init__.py b/src/blueprints/github/__init__.py index 25dac7f..e634669 100644 --- a/src/blueprints/github/__init__.py +++ b/src/blueprints/github/__init__.py @@ -1,134 +1,2 @@ -from os import environ - -import aiohttp -from quart import ( - Blueprint, - make_response, - redirect, - render_template, - request, - session, - url_for, -) -from quart_auth import AuthUser, current_user, login_required, login_user, logout_user -from sqlalchemy import update - -import src.errors as errors -from src.database import Session, User - -blueprint = Blueprint( - "github", - __name__, - template_folder="templates", - static_folder="static", - static_url_path="/static/github", -) - - -@blueprint.route("/github/get-login-url") -async def login(): - return { - "url": f"https://github.com/login/oauth/authorize?client_id={environ.get('GITHUB_ID')}" - } - - -@blueprint.route("/github/loggedin") -async def loggedin(): - return await render_template("logged_in.jinja") - - -@blueprint.route("/github/logout") -@login_required -async def logout(): - logout_user() - return await render_template("logged_out.jinja") - - -@blueprint.route("/github/logout-term") -async def logout_term(): - if not await current_user.is_authenticated: - msg = "You are not logged in!" - raise errors.UnauthorizedError(msg) - logout_user() - return {"success": True} - - -@blueprint.route("/github/callback", methods=["GET"]) -async def callback(): - next = session.get("next") - request_token = request.args.get("code") - - if request_token is None: - msg = "Bad request. Expected arguments: 'request_token'" - raise errors.RequestError(msg) - - try: - access_token = await get_access_token(request_token) - user_data = await get_user_data(access_token) - except RuntimeError as e: - raise errors.ServerError(e) - - with Session() as db_session: - if db_session.query(User).filter(User.id == user_data["id"]).count() == 0: - db_session.add(User(id=user_data["id"], login=user_data["login"])) - elif ( - db_session.query(User).filter(User.login == user_data["login"]).count() == 0 - ): - db_session.execute( - update(User) - .where(User.id == user_data["id"]) - .values(login=user_data["login"]) - ) - db_session.commit() - db_session.close() - - login_user(AuthUser(user_data["id"])) - - if next is not None: - return redirect(next) - - response = await make_response(redirect(url_for("github.loggedin"))) - response.set_cookie("logged_in", str(await current_user.is_authenticated)) - return response - - -async def get_access_token(request_token: str) -> str: - url = f"https://github.com/login/oauth/access_token?client_id={environ.get('GITHUB_ID')}&client_secret={environ.get('GITHUB_SECRET')}&code={request_token}" - headers = {"accept": "application/json"} - async with ( - aiohttp.ClientSession() as session, - await session.post(url, headers=headers) as response, - ): - if not response.ok: - msg = f"Unable to get access token from GitHub OAuth login flow! Received code HTTP {response.status}." - raise RuntimeError(msg) - try: - data: dict = await response.json() - except aiohttp.ContentTypeError: - msg = "GitHub OAuth login flow did not return a JSON object, but still returned HTTP 200. If you see this, you're fucked." - raise RuntimeError(msg) - if data.get("access_token") is None: - msg = "GitHub OAuth login flow did not return an access_token, but still returned HTTP 200. What. the fuck?" - raise RuntimeError(msg) - - return data["access_token"] - - -async def get_user_data(access_token: str) -> dict: - url = "https://api.github.com/user" - headers = {"Authorization": f"token {access_token}"} - async with ( - aiohttp.ClientSession() as session, - await session.get(url, headers=headers) as response, - ): - if not response.ok: - msg = f"Unable to get user data from the GitHub API! Received code HTTP {response.status}." - raise RuntimeError(msg) - try: - data: dict = await response.json() - except aiohttp.ContentTypeError: - msg = "GitHub API did not return a JSON object, but still returned HTTP 200. What the hell happened." - raise RuntimeError(msg) - if data.get("id") is None: - msg = "GitHub API did not return proper user data (missing 'id'), but still returned HTTP 200. Why. What." - raise RuntimeError(msg) +__all__ = ["blueprint"] +from src.blueprints.github.blueprint import blueprint diff --git a/src/blueprints/github/blueprint.py b/src/blueprints/github/blueprint.py new file mode 100644 index 0000000..b02dc54 --- /dev/null +++ b/src/blueprints/github/blueprint.py @@ -0,0 +1,135 @@ +from os import environ + +import aiohttp +from quart import ( + Blueprint, + make_response, + redirect, + render_template, + request, + session, + url_for, +) +from quart_auth import AuthUser, current_user, login_required, login_user, logout_user +from sqlalchemy import update + +import src.errors as errors +from src.database import Session, User +from src.responses import SuccessResponse, URLResponse + +blueprint = Blueprint( + "github", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static/github", +) + + +@blueprint.route("/github/get-login-url") +async def login(): + return URLResponse( + url=f"https://github.com/login/oauth/authorize?client_id={environ.get('GITHUB_ID')}" + ) + + +@blueprint.route("/github/loggedin") +async def loggedin(): + return await render_template("logged_in.jinja") + + +@blueprint.route("/github/logout") +@login_required +async def logout(): + logout_user() + return await render_template("logged_out.jinja") + + +@blueprint.route("/github/logout-term") +async def logout_term(): + if not await current_user.is_authenticated: + msg = "You are not logged in!" + raise errors.UnauthorizedError(msg) + logout_user() + return SuccessResponse() + + +@blueprint.route("/github/callback", methods=["GET"]) +async def callback(): + next = session.get("next") + request_token = request.args.get("code") + + if request_token is None: + msg = "Bad request. Expected arguments: 'request_token'" + raise errors.RequestError(msg) + + try: + access_token = await get_access_token(request_token) + user_data = await get_user_data(access_token) + except RuntimeError as e: + raise errors.ServerError(e) + + with Session() as db_session: + if db_session.query(User).filter(User.id == user_data["id"]).count() == 0: + db_session.add(User(id=user_data["id"], login=user_data["login"])) + elif ( + db_session.query(User).filter(User.login == user_data["login"]).count() == 0 + ): + db_session.execute( + update(User) + .where(User.id == user_data["id"]) + .values(login=user_data["login"]) + ) + db_session.commit() + db_session.close() + + login_user(AuthUser(user_data["id"])) + + if next is not None: + return redirect(next) + + response = await make_response(redirect(url_for("github.loggedin"))) + response.set_cookie("logged_in", str(await current_user.is_authenticated)) + return response + + +async def get_access_token(request_token: str) -> str: + url = f"https://github.com/login/oauth/access_token?client_id={environ.get('GITHUB_ID')}&client_secret={environ.get('GITHUB_SECRET')}&code={request_token}" + headers = {"accept": "application/json"} + async with ( + aiohttp.ClientSession() as session, + await session.post(url, headers=headers) as response, + ): + if not response.ok: + msg = f"Unable to get access token from GitHub OAuth login flow! Received code HTTP {response.status}." + raise RuntimeError(msg) + try: + data: dict = await response.json() + except aiohttp.ContentTypeError: + msg = "GitHub OAuth login flow did not return a JSON object, but still returned HTTP 200. If you see this, you're fucked." + raise RuntimeError(msg) + if data.get("access_token") is None: + msg = "GitHub OAuth login flow did not return an access_token, but still returned HTTP 200. What. the fuck?" + raise RuntimeError(msg) + + return data["access_token"] + + +async def get_user_data(access_token: str) -> dict: + url = "https://api.github.com/user" + headers = {"Authorization": f"token {access_token}"} + async with ( + aiohttp.ClientSession() as session, + await session.get(url, headers=headers) as response, + ): + if not response.ok: + msg = f"Unable to get user data from the GitHub API! Received code HTTP {response.status}." + raise RuntimeError(msg) + try: + data: dict = await response.json() + except aiohttp.ContentTypeError: + msg = "GitHub API did not return a JSON object, but still returned HTTP 200. What the hell happened." + raise RuntimeError(msg) + if data.get("id") is None: + msg = "GitHub API did not return proper user data (missing 'id'), but still returned HTTP 200. Why. What." + raise RuntimeError(msg) diff --git a/src/blueprints/index/__init__.py b/src/blueprints/index/__init__.py index f0df2c2..1ea33ee 100644 --- a/src/blueprints/index/__init__.py +++ b/src/blueprints/index/__init__.py @@ -1,29 +1,2 @@ -from quart import Blueprint, render_template, send_from_directory - -from src.about import is_dev - -blueprint = Blueprint( - "index", - __name__, - template_folder="templates", - static_folder="static", - static_url_path="/static/index", -) - - -@blueprint.route("/") -async def index(): - return await render_template( - "index.jinja", dev=" secret dev build" if is_dev else "" - ) - - -@blueprint.route("/legacy") -async def legacy(): - return await render_template("legacy.jinja") - - -@blueprint.route("/88x31") -@blueprint.route("/88x31.png") -async def badge88x31(): - return await send_from_directory("static/images", file_name="88x31.png") +__all__ = ["blueprint"] +from src.blueprints.index.blueprint import blueprint diff --git a/src/blueprints/index/blueprint.py b/src/blueprints/index/blueprint.py new file mode 100644 index 0000000..f0df2c2 --- /dev/null +++ b/src/blueprints/index/blueprint.py @@ -0,0 +1,29 @@ +from quart import Blueprint, render_template, send_from_directory + +from src.about import is_dev + +blueprint = Blueprint( + "index", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static/index", +) + + +@blueprint.route("/") +async def index(): + return await render_template( + "index.jinja", dev=" secret dev build" if is_dev else "" + ) + + +@blueprint.route("/legacy") +async def legacy(): + return await render_template("legacy.jinja") + + +@blueprint.route("/88x31") +@blueprint.route("/88x31.png") +async def badge88x31(): + return await send_from_directory("static/images", file_name="88x31.png") diff --git a/src/blueprints/term/__init__.py b/src/blueprints/term/__init__.py index f163bea..72146de 100644 --- a/src/blueprints/term/__init__.py +++ b/src/blueprints/term/__init__.py @@ -1,201 +1,2 @@ -import os - -import anyio -from quart import Blueprint, current_app, request -from quart_auth import current_user -from sqlalchemy import func, select - -import src.errors as errors -from src.about import is_dev, version -from src.auth import Permissions -from src.database import Session, User - -links = {"readme.md": "README.md"} - -blueprint = Blueprint( - "term", - __name__, - template_folder="templates", - static_folder="static", - static_url_path="/static/term", -) -logger = None - - -@blueprint.before_app_serving -def after(): - global logger - logger = current_app.logger - - -@blueprint.route("/ls", methods=["GET"]) -async def ls(): - cwd = request.args.get("cwd") - path = request.args.get("path") - home = "static/filesystem" - cwd = cwd.replace("~", home).rstrip("/") - searchpath = "" - if path is None: - searchpath = f"{cwd}" - elif ".." in path or ".." in cwd: - return "", 403 - elif not isinstance(path, str): - return "", 400 - elif path[0] == "/": - searchpath = f"{home}{path}" - else: - searchpath = f"{cwd}/{path}" - if not os.path.exists(searchpath): - return "", 404 - logger.info(os.path.abspath(searchpath)) - files = [*os.listdir(searchpath), *links.keys()] - return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} - - -def _replace_data(line: str) -> str: - if "<{version}>" in line: - line = line.replace("<{version}>", version) - return line - - -@blueprint.route("/cat", methods=["GET"]) -async def cat(): - cwd = request.args.get("cwd") - path = request.args.get("path") - if cwd is None or path is None: - return "", 400 - if ".." in path or ".." in cwd: - return "", 403 - home = "static/filesystem" - cwd = cwd.replace("~", "").rstrip("/") - if path.lower() in links: - filepath = links[path.lower()] - else: - filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" - if not os.path.exists(filepath): - return "", 404 - async with await anyio.open_file(filepath, encoding="utf-8") as fp: - lines = await fp.readlines() - for line in list(filter(lambda e: "<{" in e and "}>" in e, lines)): - index = lines.index(line) - lines[index] = _replace_data(line) - return lines - - -@blueprint.route("/cd", methods=["GET"]) -async def cd(): - home = "static/filesystem" - cwd = request.args.get("cwd") - path = request.args.get("path", "") - if cwd is None or path is None: - return "", 400 - if len(path) == 0: - return "", 400 - if path == "~": - filepath = f"{home}" - elif path[0] == "/": - filepath = f"{home}{path}" - else: - cwd = cwd.replace("~", "").rstrip("/") - filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" - if not os.path.isdir(filepath): - return "", 403 - return "", 200 - - -@blueprint.route("/login-text", methods=["GET"]) -async def login_text(): - commit_hash = "" - if os.path.exists(".git/refs/heads/dev"): - async with await anyio.open_file(".git/refs/heads/dev") as fp: - commit_hash = (await fp.readline()).strip("\n") - - return ( - f"foxterm {version}{' dev' if is_dev else ''}" - + f"{' build ]8;;https://github.com/tailhaver/foxterm/commit/' + commit_hash + '\\' + commit_hash[:7] + ']8;;\\' if commit_hash else ''}" - + "\r\npowered by ]8;;https://xtermjs.org/\\xterm.js]8;;", - 200, - ) - - -@blueprint.route("/current-user", methods=["GET"]) -async def get_current_user(): - return {"login": await current_user.login} - - -@blueprint.route("/admin/view_users", methods=["GET"]) -async def view_users(): - if not await current_user.has_permission( - Permissions.ADMIN | Permissions.VIEW_USERS - ): - raise errors.PermissionError() - - return {"success": True} - - -@blueprint.route("/admin/users-source", methods=["GET"]) -async def users_source(): - if not await current_user.has_permission( - Permissions.ADMIN | Permissions.VIEW_USERS - ): - raise errors.PermissionError() - - draw = int(request.args.get("draw", 0)) - start = int(request.args.get("start", 0)) - limit = int(request.args.get("length", 10)) - search_value = request.args.get("search[value]", "") - order_dir = request.args.get("order[0][dir]", "asc") - order_col = int(request.args.get("order[0][column]", "0")) - filters = True - match order_col: - case 0: - sort = User.id - case 1: - sort = User.login - case 2: - sort = User.permissions - case _: - sort = User.login - _col = order_col - 2 - filters = User.permissions.bitwise_and(Permissions.sort()[_col]) - if order_dir == "asc": - filters = filters == Permissions.sort()[_col] - else: - filters = filters != Permissions.sort()[_col] - match order_dir: - case "asc": - sort = sort.asc() - case _: - sort = sort.desc() - try: - with Session() as db_session: - data = [ - [e.id, e.login, e.permissions] - for e in db_session.execute( - select(User) - .where(User.login.contains(search_value)) - .where(filters) - .order_by(sort) - .offset(start) - .limit(limit) - ) - .scalars() - .all() - ] - total_records = db_session.scalar(select(func.count()).select_from(User)) - filtered_records = db_session.scalar( - select(func.count()) - .select_from(User) - .where(User.login.contains(search_value)) - ) - except Exception: - return { - "success": False, - "error": "An internal server error has occurred!", - }, 500 - return { - "draw": draw, - "recordsTotal": total_records, - "recordsFiltered": filtered_records, - "data": data, - } +__all__ = ["blueprint"] +from src.blueprints.term.blueprint import blueprint diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py new file mode 100644 index 0000000..090a31a --- /dev/null +++ b/src/blueprints/term/blueprint.py @@ -0,0 +1,199 @@ +import os + +import anyio +from quart import Blueprint, current_app, request +from quart_auth import current_user +from sqlalchemy import func, select + +import src.errors as errors +from src.about import is_dev, version +from src.auth import Permissions +from src.database import Session, User +from src.responses import SuccessResponse + +links = {"readme.md": "README.md"} + +blueprint = Blueprint( + "term", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static/term", +) +logger = None + + +@blueprint.before_app_serving +def after(): + global logger + logger = current_app.logger + + +@blueprint.route("/ls", methods=["GET"]) +async def ls(): + cwd = request.args.get("cwd") + path = request.args.get("path") + home = "static/filesystem" + cwd = cwd.replace("~", home).rstrip("/") + searchpath = "" + if path is None: + searchpath = f"{cwd}" + elif ".." in path or ".." in cwd: + return "", 403 + elif not isinstance(path, str): + return "", 400 + elif path[0] == "/": + searchpath = f"{home}{path}" + else: + searchpath = f"{cwd}/{path}" + if not os.path.exists(searchpath): + return "", 404 + logger.info(os.path.abspath(searchpath)) + files = [*os.listdir(searchpath), *links.keys()] + return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} + + +def _replace_data(line: str) -> str: + if "<{version}>" in line: + line = line.replace("<{version}>", version) + return line + + +@blueprint.route("/cat", methods=["GET"]) +async def cat(): + cwd = request.args.get("cwd") + path = request.args.get("path") + if cwd is None or path is None: + return "", 400 + if ".." in path or ".." in cwd: + return "", 403 + home = "static/filesystem" + cwd = cwd.replace("~", "").rstrip("/") + if path.lower() in links: + filepath = links[path.lower()] + else: + filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" + if not os.path.exists(filepath): + return "", 404 + async with await anyio.open_file(filepath, encoding="utf-8") as fp: + lines = await fp.readlines() + for line in list(filter(lambda e: "<{" in e and "}>" in e, lines)): + index = lines.index(line) + lines[index] = _replace_data(line) + return lines + + +@blueprint.route("/cd", methods=["GET"]) +async def cd(): + home = "static/filesystem" + cwd = request.args.get("cwd") + path = request.args.get("path", "") + if cwd is None or path is None: + return "", 400 + if len(path) == 0: + return "", 400 + if path == "~": + filepath = f"{home}" + elif path[0] == "/": + filepath = f"{home}{path}" + else: + cwd = cwd.replace("~", "").rstrip("/") + filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" + if not os.path.isdir(filepath): + return "", 403 + return "", 200 + + +@blueprint.route("/login-text", methods=["GET"]) +async def login_text(): + commit_hash = "" + if os.path.exists(".git/refs/heads/dev"): + async with await anyio.open_file(".git/refs/heads/dev") as fp: + commit_hash = (await fp.readline()).strip("\n") + + return ( + f"foxterm {version}{' dev' if is_dev else ''}" + + f"{' build ]8;;https://github.com/tailhaver/foxterm/commit/' + commit_hash + '\\' + commit_hash[:7] + ']8;;\\' if commit_hash else ''}" + + "\r\npowered by ]8;;https://xtermjs.org/\\xterm.js]8;;", + 200, + ) + + +@blueprint.route("/current-user", methods=["GET"]) +async def get_current_user(): + return {"login": await current_user.login} # TODO: make UserResponse obj + + +@blueprint.route("/admin/view_users", methods=["GET"]) +async def view_users(): + if not await current_user.has_permission( + Permissions.ADMIN | Permissions.VIEW_USERS + ): + raise errors.PermissionError() + + return SuccessResponse() + + +@blueprint.route("/admin/users-source", methods=["GET"]) +async def users_source(): + if not await current_user.has_permission( + Permissions.ADMIN | Permissions.VIEW_USERS + ): + raise errors.PermissionError() + + draw = int(request.args.get("draw", 0)) + start = int(request.args.get("start", 0)) + limit = int(request.args.get("length", 10)) + search_value = request.args.get("search[value]", "") + order_dir = request.args.get("order[0][dir]", "asc") + order_col = int(request.args.get("order[0][column]", "0")) + filters = True + match order_col: + case 0: + sort = User.id + case 1: + sort = User.login + case 2: + sort = User.permissions + case _: + sort = User.login + _col = order_col - 2 + filters = User.permissions.bitwise_and(Permissions.sort()[_col]) + if order_dir == "asc": + filters = filters == Permissions.sort()[_col] + else: + filters = filters != Permissions.sort()[_col] + match order_dir: + case "asc": + sort = sort.asc() + case _: + sort = sort.desc() + try: + with Session() as db_session: + data = [ + [e.id, e.login, e.permissions] + for e in db_session.execute( + select(User) + .where(User.login.contains(search_value)) + .where(filters) + .order_by(sort) + .offset(start) + .limit(limit) + ) + .scalars() + .all() + ] + total_records = db_session.scalar(select(func.count()).select_from(User)) + filtered_records = db_session.scalar( + select(func.count()) + .select_from(User) + .where(User.login.contains(search_value)) + ) + except Exception: + raise errors.ServerError() + return { # TODO: make dataclass obj + "draw": draw, + "recordsTotal": total_records, + "recordsFiltered": filtered_records, + "data": data, + } diff --git a/src/responses/__init__.py b/src/responses/__init__.py new file mode 100644 index 0000000..63ecb1c --- /dev/null +++ b/src/responses/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["ErrorResponse", "SuccessResponse", "URLResponse"] +from src.responses._error_response import ErrorResponse +from src.responses._success_response import SuccessResponse +from src.responses._url_response import URLResponse diff --git a/src/responses/_base_response.py b/src/responses/_base_response.py new file mode 100644 index 0000000..5f3ca6f --- /dev/null +++ b/src/responses/_base_response.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class FoxtermResponse: + success: bool diff --git a/src/responses/_error_response.py b/src/responses/_error_response.py new file mode 100644 index 0000000..5e174a6 --- /dev/null +++ b/src/responses/_error_response.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from src.responses._base_response import FoxtermResponse + + +@dataclass(kw_only=True) +class ErrorResponse(FoxtermResponse): + success: bool = False + error: str diff --git a/src/responses/_success_response.py b/src/responses/_success_response.py new file mode 100644 index 0000000..1677729 --- /dev/null +++ b/src/responses/_success_response.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from src.responses._base_response import FoxtermResponse + + +@dataclass(kw_only=True) +class SuccessResponse(FoxtermResponse): + success: bool = True diff --git a/src/responses/_url_response.py b/src/responses/_url_response.py new file mode 100644 index 0000000..0a4b49c --- /dev/null +++ b/src/responses/_url_response.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from src.responses._success_response import SuccessResponse + + +@dataclass(kw_only=True) +class URLResponse(SuccessResponse): + url: str diff --git a/uv.lock b/uv.lock index 0befb0d..43cd2cb 100644 --- a/uv.lock +++ b/uv.lock @@ -130,6 +130,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792 }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -237,6 +246,7 @@ dependencies = [ { name = "quart" }, { name = "quart-auth" }, { name = "quart-cors" }, + { name = "quart-schema", extra = ["pydantic"] }, { name = "sqlalchemy", extra = ["asyncio"] }, ] @@ -254,6 +264,7 @@ requires-dist = [ { name = "quart", specifier = ">=0.20.0" }, { name = "quart-auth", specifier = ">=0.11.0" }, { name = "quart-cors", specifier = ">=0.8.0" }, + { name = "quart-schema", extras = ["pydantic"], specifier = ">=0.23.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" }, ] @@ -749,6 +760,101 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +] + +[[package]] +name = "pyhumps" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/83/fa6f8fb7accb21f39e8f2b6a18f76f6d90626bdb0a5e5448e5cc9b8ab014/pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3", size = 9018 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/11/a1938340ecb32d71e47ad4914843775011e6e9da59ba1229f181fef3119e/pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6", size = 6095 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -839,6 +945,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/31/da390a5a10674481dea2909178973de81fa3a246c0eedcc0e1e4114f52f8/quart_cors-0.8.0-py3-none-any.whl", hash = "sha256:62dc811768e2e1704d2b99d5880e3eb26fc776832305a19ea53db66f63837767", size = 8698 }, ] +[[package]] +name = "quart-schema" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyhumps" }, + { name = "quart" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/65/97b69c76bc8838f0389387c87f480382eea48ca60d5262aeaf4086ad14e2/quart_schema-0.23.0.tar.gz", hash = "sha256:778f36aa80697420a0148807eb324b7d6ca1f10793cd1d0eb4f1c7908d860bdd", size = 24485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/ba/54c4516499bf6549ff47d656b8dc8cd58cea7f6d03d3097aebf1958f4974/quart_schema-0.23.0-py3-none-any.whl", hash = "sha256:f8f217942d433954dfe9860b4d748fe4b111836d8d74e06bc0afc512dd991c80", size = 21682 }, +] + +[package.optional-dependencies] +pydantic = [ + { name = "pydantic" }, +] + [[package]] name = "ruff" version = "0.14.13" @@ -914,6 +1038,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "virtualenv" version = "20.36.1" From d2136d9f1e20131f6bc17bc6764b3a20dddb6bbe Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:01:23 -0500 Subject: [PATCH 04/41] add test deploy workflow --- .github/workflows/deploy.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..960deb4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,17 @@ +name: test for deploy action +on: + push: + branches: [ dev ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + port: ${{ secrets.PORT }} + script: whoami From ff702cfe61f4bea3573c7ebb4aab0fe22ac0219d Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:04:55 -0500 Subject: [PATCH 05/41] fix test deploy --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 960deb4..4f6ca37 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: deploy - uses: appleboy/ssh-action@master + uses: appleboy/ssh-action@v1 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} From f26a8b91b949a9aa199f1a66d0ebafa4029e28ab Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:12:16 -0500 Subject: [PATCH 06/41] please. --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4f6ca37..7a58969 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,9 +5,10 @@ on: jobs: deploy: + name: deploy runs-on: ubuntu-latest steps: - - name: deploy + - name: Execute remote SSH commands using SSH key uses: appleboy/ssh-action@v1 with: host: ${{ secrets.HOST }} From 971f76f1c97d1acfbc7d1b1ba73ef4e2bd837023 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:21:20 -0500 Subject: [PATCH 07/41] what --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7a58969..6761042 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,4 +15,4 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} - script: whoami + script: ls From 85e314a7779ae7c34524fff930a513275afa2bd0 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:26:26 -0500 Subject: [PATCH 08/41] modify deploy script to actually deploy on dev --- .github/workflows/{deploy.yml => deploy-dev.yml} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{deploy.yml => deploy-dev.yml} (74%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-dev.yml similarity index 74% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-dev.yml index 6761042..4a48a67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,11 +1,11 @@ -name: test for deploy action +name: Deploy to dev server (https://dev.yip.cat) on: push: branches: [ dev ] jobs: - deploy: - name: deploy + deploy-dev: + name: deploy-dev runs-on: ubuntu-latest steps: - name: Execute remote SSH commands using SSH key @@ -15,4 +15,4 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} - script: ls + script: deploy-dev.sh From b1f3a9d542de26100666b88418eb6b6cb8fd9595 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:27:01 -0500 Subject: [PATCH 09/41] i am an idiot --- .github/workflows/deploy-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 4a48a67..0ba2845 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -15,4 +15,4 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} - script: deploy-dev.sh + script: ./deploy-dev.sh From a5eb571058497095b238341d26c55b45dffc53ba Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:55:54 -0500 Subject: [PATCH 10/41] feat: remove config classes, automatically load .env opt to load relevant config data from the .env rather than classes. update .env.example with `QUART_TESTING` field --- .env.example | 1 + __init__.py | 6 ++++-- pyproject.toml | 2 ++ src/app/app.py | 3 --- src/config/__init__.py | 3 --- src/config/config.py | 16 ---------------- uv.lock | 24 ++++++++++++++++++++++++ 7 files changed, 31 insertions(+), 24 deletions(-) delete mode 100644 src/config/__init__.py delete mode 100644 src/config/config.py diff --git a/.env.example b/.env.example index da89ffa..a078fc2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ QUART_DEBUG=true +QUART_TESTING=true QUART_AUTH_COOKIE_SECURE= QUART_SECRET_KEY='' QUART_SUBDOMAIN_MATCHING=false diff --git a/__init__.py b/__init__.py index f8ef870..9fd29d9 100644 --- a/__init__.py +++ b/__init__.py @@ -1,10 +1,12 @@ -from __future__ import annotations - from logging import getLogger +from dotenv import load_dotenv + from src.about import is_dev from src.app import create_app +load_dotenv() + app = create_app(__name__) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 07a7109..e328a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "aiohttp>=3.13.2", "aiosqlite>=0.21.0", "anyio>=4.12.1", + "dotenv>=0.9.9", + "pyyaml>=6.0.3", "quart>=0.20.0", "quart-auth>=0.11.0", "quart-cors>=0.8.0", diff --git a/src/app/app.py b/src/app/app.py index c7996f6..ae6aadc 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -20,12 +20,9 @@ def create_app(import_name: str) -> Quart: app.config.from_prefixed_env("QUART") app.asgi_app = ASGIMiddleware(app.asgi_app) if app.config["DEBUG"]: - config_mode = "Development" app.logger.info("Loading Development configuration...") else: - config_mode = "Production" app = cors(app, allow_origin=re.compile("https://*.yip.cat*")) - app.config.from_object(f"src.config.{config_mode}") auth_manager = QuartAuth(app) auth_manager.user_class = User diff --git a/src/config/__init__.py b/src/config/__init__.py deleted file mode 100644 index ccb12c1..0000000 --- a/src/config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["Development", "Production"] - -from .config import Development, Production diff --git a/src/config/config.py b/src/config/config.py deleted file mode 100644 index bbbad86..0000000 --- a/src/config/config.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Config file for quart app""" - - -class Config: - DEBUG = False - TESTING = False - - -class Production(Config): - SERVER_NAME = "yip.cat" - - -class Development(Config): - DEBUG = True - TESTING = True - AUTH_COOKIE_SECURE = False diff --git a/uv.lock b/uv.lock index 43cd2cb..fb3a5fd 100644 --- a/uv.lock +++ b/uv.lock @@ -209,6 +209,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -243,6 +254,8 @@ dependencies = [ { name = "aiohttp" }, { name = "aiosqlite" }, { name = "anyio" }, + { name = "dotenv" }, + { name = "pyyaml" }, { name = "quart" }, { name = "quart-auth" }, { name = "quart-cors" }, @@ -261,6 +274,8 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "anyio", specifier = ">=4.12.1" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "quart", specifier = ">=0.20.0" }, { name = "quart-auth", specifier = ">=0.11.0" }, { name = "quart-cors", specifier = ">=0.8.0" }, @@ -855,6 +870,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/11/a1938340ecb32d71e47ad4914843775011e6e9da59ba1229f181fef3119e/pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6", size = 6095 }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 94058d3890810ba537eaab00faf469cffe2f804b Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:25:45 -0500 Subject: [PATCH 11/41] feat(blueprints): add blueprint-config generation and blueprint toggles --- .gitignore | 3 +- src/blueprints/__init__.py | 76 +++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 71cc8cf..29af6b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ .env *.db -*.log \ No newline at end of file +*.log +blueprint-config.yaml \ No newline at end of file diff --git a/src/blueprints/__init__.py b/src/blueprints/__init__.py index c5f7a0d..4d080b7 100644 --- a/src/blueprints/__init__.py +++ b/src/blueprints/__init__.py @@ -1,11 +1,24 @@ +from __future__ import annotations + __all__ = ["blueprints"] import importlib +import logging +import os import pkgutil +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from quart import Blueprint + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) __path__ = pkgutil.extend_path(__path__, __name__) -blueprints = [] +blueprints: list[Blueprint] = [] for loader, module_name, is_package in pkgutil.walk_packages(__path__): full_module_name = f"{__name__}.{module_name}" @@ -13,3 +26,64 @@ if hasattr(module, "blueprint"): blueprints.append(module.blueprint) + + +class BlueprintYAMLChecker: + YAML_FILENAME = "blueprint-config.yaml" + + @staticmethod + def main() -> None: + BlueprintYAMLChecker.check_yaml_integration() + BlueprintYAMLChecker.apply_yaml_config() + + @staticmethod + def check_yaml_integration() -> None: + if not os.path.exists(BlueprintYAMLChecker.YAML_FILENAME): + msg = ( + "It seems like BlueprintYAMLChecker is your first time running Foxterm!" + ) + logger.warning(msg) + BlueprintYAMLChecker.write_yaml_config() + return + + with open(BlueprintYAMLChecker.YAML_FILENAME) as stream: + data: dict = yaml.safe_load(stream) + + if ( + data is None + or data.get("enabled") is None + or data["enabled"].keys() + != BlueprintYAMLChecker.generate_yaml_config()["enabled"].keys() + ): + msg = f"Your {BlueprintYAMLChecker.YAML_FILENAME} file is out of date!" + logger.warning(msg) + BlueprintYAMLChecker.write_yaml_config() + + @staticmethod + def generate_yaml_config() -> dict: + return { + "enabled": dict.fromkeys([blueprint.name for blueprint in blueprints], True) + } + + @staticmethod + def write_yaml_config() -> None: + msg = f"Writing {BlueprintYAMLChecker.YAML_FILENAME}..." + logger.warning(msg) + + with open(BlueprintYAMLChecker.YAML_FILENAME, "w+") as stream: + yaml.safe_dump(BlueprintYAMLChecker.generate_yaml_config(), stream) + + @staticmethod + def apply_yaml_config() -> None: + global blueprints + with open(BlueprintYAMLChecker.YAML_FILENAME) as stream: + data: dict = yaml.safe_load(stream) + + blueprints = [ + blueprint + for (blueprint, enabled) in zip(blueprints, data["enabled"].values()) + if enabled + ] + + +BlueprintYAMLChecker.main() From ce4a28cd47b31b54f78fd68df3e6ebd4faf02156 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:26:54 -0500 Subject: [PATCH 12/41] perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls --- src/blueprints/term/blueprint.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py index 090a31a..eb74282 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/term/blueprint.py @@ -46,11 +46,10 @@ async def ls(): searchpath = f"{home}{path}" else: searchpath = f"{cwd}/{path}" - if not os.path.exists(searchpath): + if not await anyio.Path(searchpath).exists(): return "", 404 - logger.info(os.path.abspath(searchpath)) files = [*os.listdir(searchpath), *links.keys()] - return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} + return {k: {"isDir": await anyio.Path(f"{searchpath}/{k}").is_dir()} for k in files} def _replace_data(line: str) -> str: @@ -73,7 +72,8 @@ async def cat(): filepath = links[path.lower()] else: filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" - if not os.path.exists(filepath): + + if not await anyio.Path(filepath).exists(): return "", 404 async with await anyio.open_file(filepath, encoding="utf-8") as fp: lines = await fp.readlines() @@ -99,7 +99,8 @@ async def cd(): else: cwd = cwd.replace("~", "").rstrip("/") filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" - if not os.path.isdir(filepath): + + if not await anyio.Path(filepath).is_dir(): return "", 403 return "", 200 @@ -107,8 +108,9 @@ async def cd(): @blueprint.route("/login-text", methods=["GET"]) async def login_text(): commit_hash = "" - if os.path.exists(".git/refs/heads/dev"): - async with await anyio.open_file(".git/refs/heads/dev") as fp: + path = anyio.Path(".git/refs/heads/dev") + if await path.exists(): + async with await anyio.open_file(path) as fp: commit_hash = (await fp.readline()).strip("\n") return ( From ddaec176d28842d8d938ba17ebca4275acd9f2b8 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:27:33 -0500 Subject: [PATCH 13/41] docs: add docstrings for middleware, `src.errors`, and `src.responses` --- src/app/middleware.py | 7 ++++++- src/errors/_foxterm_error.py | 12 ++++++++++++ src/errors/_permission_error.py | 6 ++++++ src/errors/_request_error.py | 6 ++++++ src/errors/_server_error.py | 6 ++++++ src/errors/_unauthorized_error.py | 6 ++++++ src/responses/_base_response.py | 8 ++++++++ src/responses/_error_response.py | 9 +++++++++ src/responses/_success_response.py | 8 ++++++++ src/responses/_url_response.py | 11 +++++++++++ 10 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/app/middleware.py b/src/app/middleware.py index c88dff9..5ef39cd 100644 --- a/src/app/middleware.py +++ b/src/app/middleware.py @@ -14,7 +14,12 @@ class ASGIMiddleware: - """Custom middleware to remove "dev." subdomain from HTTP requests""" + """ + Custom middleware to remove "dev." subdomain from HTTP requests. + + Entirely optional, but used to host a redirect from dev.site.tld to site.tld + through something like CloudFlare + """ # this is my baby. she is deformed. # be nice to my baby. diff --git a/src/errors/_foxterm_error.py b/src/errors/_foxterm_error.py index 9c2dc1a..84a6045 100644 --- a/src/errors/_foxterm_error.py +++ b/src/errors/_foxterm_error.py @@ -1,5 +1,17 @@ class FoxtermError(Exception): + """ + Default error class used to return a JSON object with error information + from a Foxterm backend request rather than returning one manually. + """ + def __init__(self, status_code: int, error: str | Exception) -> None: + """Throws an error to be caught by the Quart application, to return a set + JSON object and status code. + + Args: + status_code (int): HTTP status code to return the response with. + error (str | Exception): An error string, or an Exception to be stringified. + """ if isinstance(error, Exception): error = str(error) self.status_code = status_code diff --git a/src/errors/_permission_error.py b/src/errors/_permission_error.py index dbdd040..3c5982d 100644 --- a/src/errors/_permission_error.py +++ b/src/errors/_permission_error.py @@ -2,6 +2,12 @@ class PermissionError(FoxtermError): + """ + Raises an error that will return a response with code HTTP 403: Forbidden. + + Default message: "You do not have permission to run this command." + """ + def __init__(self, error_message: str | None = None) -> None: if error_message is None: error_message = "You do not have permission to run this command." diff --git a/src/errors/_request_error.py b/src/errors/_request_error.py index ef5633e..5579766 100644 --- a/src/errors/_request_error.py +++ b/src/errors/_request_error.py @@ -2,6 +2,12 @@ class RequestError(FoxtermError): + """ + Raises an error that will return a response with code HTTP 400: Bad Request. + + Default message: "Malformed Request." + """ + def __init__(self, error_message: str | None = None) -> None: if error_message is None: error_message = "Malformed request." diff --git a/src/errors/_server_error.py b/src/errors/_server_error.py index ffefc7d..20f83ed 100644 --- a/src/errors/_server_error.py +++ b/src/errors/_server_error.py @@ -2,6 +2,12 @@ class ServerError(FoxtermError): + """ + Raises an error that will return a response with code HTTP 500: Internal Server Error. + + Default message: "An internal server error has occurred." + """ + def __init__(self, error_message: str | None = None) -> None: if error_message is None: error_message = "An internal server error has occurred." diff --git a/src/errors/_unauthorized_error.py b/src/errors/_unauthorized_error.py index b7dc920..90a8c95 100644 --- a/src/errors/_unauthorized_error.py +++ b/src/errors/_unauthorized_error.py @@ -2,6 +2,12 @@ class UnauthorizedError(FoxtermError): + """ + Raises an error that will return a response with code HTTP 401: Unauthorized. + + Default message: "You are not authorized to run this command." + """ + def __init__(self, error_message: str | Exception | None = None) -> None: if error_message is None: error_message = "You are not authorized to run this command." diff --git a/src/responses/_base_response.py b/src/responses/_base_response.py index 5f3ca6f..e2f94ed 100644 --- a/src/responses/_base_response.py +++ b/src/responses/_base_response.py @@ -3,4 +3,12 @@ @dataclass(kw_only=True) class FoxtermResponse: + """ + Base dataclass for all responses from the Foxterm backend. Each kwarg is a key in + the response's JSON object. + + Keys: + success (bool) + """ + success: bool diff --git a/src/responses/_error_response.py b/src/responses/_error_response.py index 5e174a6..9f069f6 100644 --- a/src/responses/_error_response.py +++ b/src/responses/_error_response.py @@ -5,5 +5,14 @@ @dataclass(kw_only=True) class ErrorResponse(FoxtermResponse): + """ + Base error response from the Foxterm backend. Each kwarg is a key in + the response's JSON object. + + Keys: + success (bool) = False + error (str) + """ + success: bool = False error: str diff --git a/src/responses/_success_response.py b/src/responses/_success_response.py index 1677729..80f110d 100644 --- a/src/responses/_success_response.py +++ b/src/responses/_success_response.py @@ -5,4 +5,12 @@ @dataclass(kw_only=True) class SuccessResponse(FoxtermResponse): + """ + Base success response from the Foxterm backend. Each kwarg is a key in + the response's JSON object. + + Keys: + success (bool) = True + """ + success: bool = True diff --git a/src/responses/_url_response.py b/src/responses/_url_response.py index 0a4b49c..cd1062e 100644 --- a/src/responses/_url_response.py +++ b/src/responses/_url_response.py @@ -5,4 +5,15 @@ @dataclass(kw_only=True) class URLResponse(SuccessResponse): + """ + Dataclass used to return a URL from the Foxterm backend. Each kwarg is a key in + the response's JSON object. + + This is, currently, only ever used to send the GitHub OAuth link to the frontend. + + Keys: + success (bool) = True + url (str) + """ + url: str From 55956b55f34dcd3b0cf5ebf354dc61fc917c4846 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:48:44 -0500 Subject: [PATCH 14/41] feat(server): add `POR`T environment variable, change binding from --- .env.example | 3 ++- __init__.py | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index a078fc2..bad4385 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,5 @@ QUART_SUBDOMAIN_MATCHING=false GITHUB_ID='' GITHUB_SECRET='' SERVER_NAME='' -DATABASE_PATH='' \ No newline at end of file +DATABASE_PATH='' +PORT=5000 \ No newline at end of file diff --git a/__init__.py b/__init__.py index 9fd29d9..28f53ca 100644 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,8 @@ from logging import getLogger +from os import environ from dotenv import load_dotenv -from src.about import is_dev from src.app import create_app load_dotenv() @@ -11,9 +11,8 @@ if __name__ == "__main__": if app.config["DEBUG"]: - app.run(port=5000) + app.run(port=environ.get("PORT", 5000)) else: getLogger("hypercorn.access").disabled = True getLogger("hypercorn.error").disabled = True - app.run(host="0.0.0.0", port=80 if not is_dev else 1080) # noqa: S104 - # TODO: fix S104 above and add port env var + app.run(host="127.0.0.1", port=environ.get("PORT", 80)) From 5f2f0fc29c2b383842e7f26e210f0af94cde8232 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:51:52 -0500 Subject: [PATCH 15/41] chore: bump version --- src/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/about.py b/src/about.py index 95d2728..13e7751 100644 --- a/src/about.py +++ b/src/about.py @@ -1,6 +1,6 @@ import os -version = "0.7.0" +version = "0.7.1" if os.path.exists(".git/HEAD"): with open(".git/HEAD") as fp: From bc67d19a5ef6d52181a34089d3ed715082cbb9a0 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:13:27 -0500 Subject: [PATCH 16/41] ops: add main branch deploy script --- .github/workflows/deploy.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2a8aca8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,18 @@ +name: Deploy to main server (https://yip.cat) +on: + push: + branches: [ main ] + +jobs: + deploy-dev: + name: deploy-dev + runs-on: ubuntu-latest + steps: + - name: Execute remote SSH commands using SSH key + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + port: ${{ secrets.PORT }} + script: ./deploy.sh From 92d93cfd61e75ca56438f4f0a5560e63d1676d8b Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:15:51 -0500 Subject: [PATCH 17/41] fix(ops): rename main deploy script from deploy-dev to deploy. oops! --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2a8aca8..34c3966 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,8 +4,8 @@ on: branches: [ main ] jobs: - deploy-dev: - name: deploy-dev + deploy: + name: deploy runs-on: ubuntu-latest steps: - name: Execute remote SSH commands using SSH key From fc8b750b11645742b85275b11b0c7c4e3f4ba7d2 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:42:36 -0500 Subject: [PATCH 18/41] docs(auth.permissions) --- src/auth/permissions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/auth/permissions.py b/src/auth/permissions.py index d668090..9320c66 100644 --- a/src/auth/permissions.py +++ b/src/auth/permissions.py @@ -7,6 +7,14 @@ class Permissions: @staticmethod def sort() -> list: + """Sorts a list of all available permissions by their value, making for + easier indexing without knowing the name. + + Returns: + list: Sorted list of all Permission integer values + """ + # tl;dr: shitty list comprehension that returns only uppercase, non_private variables + # DO NOT DO THIS! THIS IS BAD!!!!! probably. idk im just a fox dont listen to me _current = [ value for name, value in vars(Permissions).items() From 78184c2b24608f67955500c3c9278c4e3d75e5cc Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:43:06 -0500 Subject: [PATCH 19/41] feat(app): add CORS_REGEX environment variable --- .env.example | 3 ++- src/app/app.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index bad4385..48bf608 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ GITHUB_ID='' GITHUB_SECRET='' SERVER_NAME='' DATABASE_PATH='' -PORT=5000 \ No newline at end of file +PORT=5000 +CORS_REGEX=''' \ No newline at end of file diff --git a/src/app/app.py b/src/app/app.py index ae6aadc..5f0638d 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from os import environ from quart import Quart from quart_auth import QuartAuth @@ -22,7 +23,10 @@ def create_app(import_name: str) -> Quart: if app.config["DEBUG"]: app.logger.info("Loading Development configuration...") else: - app = cors(app, allow_origin=re.compile("https://*.yip.cat*")) + app = cors( + app, + allow_origin=re.compile(environ.get("CORS_REGEX", "https://*.yip.cat*")), + ) auth_manager = QuartAuth(app) auth_manager.user_class = User From b8fad350ac75eea96c289d6c813bb52ec128e81a Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:45:48 -0500 Subject: [PATCH 20/41] feat(foxterm-backend): add commit hash to login message for all branches, not just dev --- src/blueprints/term/blueprint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py index eb74282..9e11eb1 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/term/blueprint.py @@ -108,7 +108,11 @@ async def cd(): @blueprint.route("/login-text", methods=["GET"]) async def login_text(): commit_hash = "" - path = anyio.Path(".git/refs/heads/dev") + if is_dev: + path = anyio.Path(".git/refs/heads/dev") + else: + path = anyio.Path(".git/refs/heads/main") + if await path.exists(): async with await anyio.open_file(path) as fp: commit_hash = (await fp.readline()).strip("\n") From 282e79bd092fce498a6563234be83713bfa8c122 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:48:43 -0500 Subject: [PATCH 21/41] refactor(blueprints): move term (previously foxterm-backend) to `foxterm_backend` --- src/blueprints/foxterm_backend/__init__.py | 2 ++ src/blueprints/{term => foxterm_backend}/blueprint.py | 2 +- src/blueprints/term/__init__.py | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 src/blueprints/foxterm_backend/__init__.py rename src/blueprints/{term => foxterm_backend}/blueprint.py (99%) delete mode 100644 src/blueprints/term/__init__.py diff --git a/src/blueprints/foxterm_backend/__init__.py b/src/blueprints/foxterm_backend/__init__.py new file mode 100644 index 0000000..7ef19fe --- /dev/null +++ b/src/blueprints/foxterm_backend/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["blueprint"] +from src.blueprints.foxterm_backend.blueprint import blueprint diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/foxterm_backend/blueprint.py similarity index 99% rename from src/blueprints/term/blueprint.py rename to src/blueprints/foxterm_backend/blueprint.py index 9e11eb1..49ddbc9 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/foxterm_backend/blueprint.py @@ -14,7 +14,7 @@ links = {"readme.md": "README.md"} blueprint = Blueprint( - "term", + "foxterm_backend", __name__, template_folder="templates", static_folder="static", diff --git a/src/blueprints/term/__init__.py b/src/blueprints/term/__init__.py deleted file mode 100644 index 72146de..0000000 --- a/src/blueprints/term/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__ = ["blueprint"] -from src.blueprints.term.blueprint import blueprint From bdb8afcd58d04a567b7084a286064806708da98a Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:49:01 -0500 Subject: [PATCH 22/41] chore: bump version --- src/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/about.py b/src/about.py index 13e7751..c916ed6 100644 --- a/src/about.py +++ b/src/about.py @@ -1,6 +1,6 @@ import os -version = "0.7.1" +version = "0.7.2" if os.path.exists(".git/HEAD"): with open(".git/HEAD") as fp: From b5527b0e27bcce973b812b56b6a51151a3f0d431 Mon Sep 17 00:00:00 2001 From: Taggie Date: Mon, 19 Jan 2026 01:24:23 -0500 Subject: [PATCH 23/41] code overhaul with docs, hooks, config, custom error types, etc. you might get more details in the pr fuck if i know --- src/blueprints/term/__init__.py | 204 ++++++++++++++++++++++++++++++++ src/config/__init__.py | 3 + 2 files changed, 207 insertions(+) create mode 100644 src/config/__init__.py diff --git a/src/blueprints/term/__init__.py b/src/blueprints/term/__init__.py index 72146de..9a15138 100644 --- a/src/blueprints/term/__init__.py +++ b/src/blueprints/term/__init__.py @@ -1,2 +1,206 @@ +<<<<<<< HEAD __all__ = ["blueprint"] from src.blueprints.term.blueprint import blueprint +======= +import os + +import anyio +from quart import Blueprint, current_app, request +from quart_auth import current_user +from sqlalchemy import func, select + +import src.errors as errors +from src.about import is_dev, version +from src.auth import Permissions +from src.database import Session, User + +links = {"readme.md": "README.md"} + +blueprint = Blueprint( + "term", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static/term", +) +logger = None + + +@blueprint.before_app_serving +def after(): + global logger + logger = current_app.logger + + +@blueprint.route("/ls", methods=["GET"]) +async def ls(): + cwd = request.args.get("cwd") + path = request.args.get("path") + home = "static/filesystem" + cwd = cwd.replace("~", home).rstrip("/") + searchpath = "" + if path is None: + searchpath = f"{cwd}" + elif ".." in path or ".." in cwd: + return "", 403 + elif not isinstance(path, str): + return "", 400 + elif path[0] == "/": + searchpath = f"{home}{path}" + else: + searchpath = f"{cwd}/{path}" + if not os.path.exists(searchpath): + return "", 404 + logger.info(os.path.abspath(searchpath)) + files = [*os.listdir(searchpath), *links.keys()] + return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} + + +def _replace_data(line: str) -> str: + if "<{version}>" in line: + line = line.replace("<{version}>", version) + return line + + +@blueprint.route("/cat", methods=["GET"]) +async def cat(): + cwd = request.args.get("cwd") + path = request.args.get("path") + if cwd is None or path is None: + return "", 400 + if ".." in path or ".." in cwd: + return "", 403 + home = "static/filesystem" + cwd = cwd.replace("~", "").rstrip("/") + if path.lower() in links: + filepath = links[path.lower()] + else: + filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" + if not os.path.exists(filepath): + return "", 404 + async with await anyio.open_file(filepath, encoding="utf-8") as fp: + lines = await fp.readlines() + for line in list(filter(lambda e: "<{" in e and "}>" in e, lines)): + index = lines.index(line) + lines[index] = _replace_data(line) + return lines + + +@blueprint.route("/cd", methods=["GET"]) +async def cd(): + home = "static/filesystem" + cwd = request.args.get("cwd") + path = request.args.get("path", "") + if cwd is None or path is None: + return "", 400 + if len(path) == 0: + return "", 400 + if path == "~": + filepath = f"{home}" + elif path[0] == "/": + filepath = f"{home}{path}" + else: + cwd = cwd.replace("~", "").rstrip("/") + filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" + if not os.path.isdir(filepath): + return "", 403 + return "", 200 + + +@blueprint.route("/login-text", methods=["GET"]) +async def login_text(): + commit_hash = "" + if os.path.exists(".git/refs/heads/dev"): + async with await anyio.open_file(".git/refs/heads/dev") as fp: + commit_hash = (await fp.readline()).strip("\n") + + return ( + f"foxterm {version}{' dev' if is_dev else ''}" + + f"{' build ]8;;https://github.com/tailhaver/foxterm/commit/' + commit_hash + '\\' + commit_hash[:7] + ']8;;\\' if commit_hash else ''}" + + "\r\npowered by ]8;;https://xtermjs.org/\\xterm.js]8;;", + 200, + ) + + +@blueprint.route("/current-user", methods=["GET"]) +async def get_current_user(): + return {"login": await current_user.login} + + +@blueprint.route("/admin/view_users", methods=["GET"]) +async def view_users(): + if not await current_user.has_permission( + Permissions.ADMIN | Permissions.VIEW_USERS + ): + raise errors.PermissionError() + + return {"success": True} + + +@blueprint.route("/admin/users-source", methods=["GET"]) +async def users_source(): + if not await current_user.has_permission( + Permissions.ADMIN | Permissions.VIEW_USERS + ): + raise errors.PermissionError() + + draw = int(request.args.get("draw", 0)) + start = int(request.args.get("start", 0)) + limit = int(request.args.get("length", 10)) + search_value = request.args.get("search[value]", "") + order_dir = request.args.get("order[0][dir]", "asc") + order_col = int(request.args.get("order[0][column]", "0")) + filters = True + match order_col: + case 0: + sort = User.id + case 1: + sort = User.login + case 2: + sort = User.permissions + case _: + sort = User.login + _col = order_col - 2 + filters = User.permissions.bitwise_and(Permissions.sort()[_col]) + if order_dir == "asc": + filters = filters == Permissions.sort()[_col] + else: + filters = filters != Permissions.sort()[_col] + match order_dir: + case "asc": + sort = sort.asc() + case _: + sort = sort.desc() + try: + with Session() as db_session: + data = [ + [e.id, e.login, e.permissions] + for e in db_session.execute( + select(User) + .where(User.login.contains(search_value)) + .where(filters) + .order_by(sort) + .offset(start) + .limit(limit) + ) + .scalars() + .all() + ] + total_records = db_session.scalar(select(func.count()).select_from(User)) + filtered_records = db_session.scalar( + select(func.count()) + .select_from(User) + .where(User.login.contains(search_value)) + ) + except Exception: + return { + "success": False, + "error": "An internal server error has occurred!", + }, 500 + return { + "draw": draw, + "recordsTotal": total_records, + "recordsFiltered": filtered_records, + "data": data, + } +>>>>>>> 487f1b1 (code overhaul with docs, hooks, config, custom error types, etc. you might get more details in the pr fuck if i know) diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..ccb12c1 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["Development", "Production"] + +from .config import Development, Production From 1260570d663f44549759ab00b64ef4421c57e51f Mon Sep 17 00:00:00 2001 From: Taggie Date: Mon, 2 Feb 2026 19:26:44 -0500 Subject: [PATCH 24/41] add responses/ folder, move blueprints into dedicated blueprint.py file --- src/blueprints/term/__init__.py | 5 +++++ src/blueprints/term/blueprint.py | 21 +++++++++++++++++++++ src/responses/_base_response.py | 3 +++ src/responses/_error_response.py | 3 +++ src/responses/_success_response.py | 3 +++ src/responses/_url_response.py | 3 +++ uv.lock | 3 +++ 7 files changed, 41 insertions(+) diff --git a/src/blueprints/term/__init__.py b/src/blueprints/term/__init__.py index 9a15138..cdb7a0c 100644 --- a/src/blueprints/term/__init__.py +++ b/src/blueprints/term/__init__.py @@ -1,4 +1,5 @@ <<<<<<< HEAD +<<<<<<< HEAD __all__ = ["blueprint"] from src.blueprints.term.blueprint import blueprint ======= @@ -204,3 +205,7 @@ async def users_source(): "data": data, } >>>>>>> 487f1b1 (code overhaul with docs, hooks, config, custom error types, etc. you might get more details in the pr fuck if i know) +======= +__all__ = ["blueprint"] +from src.blueprints.term.blueprint import blueprint +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py index eb74282..40bd897 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/term/blueprint.py @@ -46,10 +46,18 @@ async def ls(): searchpath = f"{home}{path}" else: searchpath = f"{cwd}/{path}" +<<<<<<< HEAD if not await anyio.Path(searchpath).exists(): return "", 404 files = [*os.listdir(searchpath), *links.keys()] return {k: {"isDir": await anyio.Path(f"{searchpath}/{k}").is_dir()} for k in files} +======= + if not os.path.exists(searchpath): + return "", 404 + logger.info(os.path.abspath(searchpath)) + files = [*os.listdir(searchpath), *links.keys()] + return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) def _replace_data(line: str) -> str: @@ -72,8 +80,12 @@ async def cat(): filepath = links[path.lower()] else: filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" +<<<<<<< HEAD if not await anyio.Path(filepath).exists(): +======= + if not os.path.exists(filepath): +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) return "", 404 async with await anyio.open_file(filepath, encoding="utf-8") as fp: lines = await fp.readlines() @@ -99,8 +111,12 @@ async def cd(): else: cwd = cwd.replace("~", "").rstrip("/") filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" +<<<<<<< HEAD if not await anyio.Path(filepath).is_dir(): +======= + if not os.path.isdir(filepath): +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) return "", 403 return "", 200 @@ -108,9 +124,14 @@ async def cd(): @blueprint.route("/login-text", methods=["GET"]) async def login_text(): commit_hash = "" +<<<<<<< HEAD path = anyio.Path(".git/refs/heads/dev") if await path.exists(): async with await anyio.open_file(path) as fp: +======= + if os.path.exists(".git/refs/heads/dev"): + async with await anyio.open_file(".git/refs/heads/dev") as fp: +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) commit_hash = (await fp.readline()).strip("\n") return ( diff --git a/src/responses/_base_response.py b/src/responses/_base_response.py index e2f94ed..b9e133d 100644 --- a/src/responses/_base_response.py +++ b/src/responses/_base_response.py @@ -3,6 +3,7 @@ @dataclass(kw_only=True) class FoxtermResponse: +<<<<<<< HEAD """ Base dataclass for all responses from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -11,4 +12,6 @@ class FoxtermResponse: success (bool) """ +======= +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) success: bool diff --git a/src/responses/_error_response.py b/src/responses/_error_response.py index 9f069f6..318732a 100644 --- a/src/responses/_error_response.py +++ b/src/responses/_error_response.py @@ -5,6 +5,7 @@ @dataclass(kw_only=True) class ErrorResponse(FoxtermResponse): +<<<<<<< HEAD """ Base error response from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -14,5 +15,7 @@ class ErrorResponse(FoxtermResponse): error (str) """ +======= +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) success: bool = False error: str diff --git a/src/responses/_success_response.py b/src/responses/_success_response.py index 80f110d..4bde05e 100644 --- a/src/responses/_success_response.py +++ b/src/responses/_success_response.py @@ -5,6 +5,7 @@ @dataclass(kw_only=True) class SuccessResponse(FoxtermResponse): +<<<<<<< HEAD """ Base success response from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -13,4 +14,6 @@ class SuccessResponse(FoxtermResponse): success (bool) = True """ +======= +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) success: bool = True diff --git a/src/responses/_url_response.py b/src/responses/_url_response.py index cd1062e..41a088e 100644 --- a/src/responses/_url_response.py +++ b/src/responses/_url_response.py @@ -5,6 +5,7 @@ @dataclass(kw_only=True) class URLResponse(SuccessResponse): +<<<<<<< HEAD """ Dataclass used to return a URL from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -16,4 +17,6 @@ class URLResponse(SuccessResponse): url (str) """ +======= +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) url: str diff --git a/uv.lock b/uv.lock index fb3a5fd..f9c2f8d 100644 --- a/uv.lock +++ b/uv.lock @@ -871,6 +871,7 @@ wheels = [ ] [[package]] +<<<<<<< HEAD name = "python-dotenv" version = "1.2.1" source = { registry = "https://pypi.org/simple" } @@ -880,6 +881,8 @@ wheels = [ ] [[package]] +======= +>>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } From 463e85d91ddd037498567e896998108ac8bde3c0 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:01:23 -0500 Subject: [PATCH 25/41] add test deploy workflow --- .github/workflows/deploy.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 34c3966..b9c0e90 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,3 +1,4 @@ +<<<<<<< HEAD name: Deploy to main server (https://yip.cat) on: push: @@ -10,9 +11,26 @@ jobs: steps: - name: Execute remote SSH commands using SSH key uses: appleboy/ssh-action@v1 +======= +name: test for deploy action +on: + push: + branches: [ dev ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: deploy + uses: appleboy/ssh-action@master +>>>>>>> d2136d9 (add test deploy workflow) with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} +<<<<<<< HEAD script: ./deploy.sh +======= + script: whoami +>>>>>>> d2136d9 (add test deploy workflow) From 67cdea039570722cbfab982af64134a0ab376096 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:04:55 -0500 Subject: [PATCH 26/41] fix test deploy --- .github/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b9c0e90..f367815 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,8 +22,12 @@ jobs: runs-on: ubuntu-latest steps: - name: deploy +<<<<<<< HEAD uses: appleboy/ssh-action@master >>>>>>> d2136d9 (add test deploy workflow) +======= + uses: appleboy/ssh-action@v1 +>>>>>>> ff702cf (fix test deploy) with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} From 4495e3c9e62d0777e74e5e31e4236eb97566765b Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:12:16 -0500 Subject: [PATCH 27/41] please. --- .github/workflows/deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f367815..5abd3f0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,13 +19,18 @@ on: jobs: deploy: + name: deploy runs-on: ubuntu-latest steps: +<<<<<<< HEAD - name: deploy <<<<<<< HEAD uses: appleboy/ssh-action@master >>>>>>> d2136d9 (add test deploy workflow) ======= +======= + - name: Execute remote SSH commands using SSH key +>>>>>>> f26a8b9 (please.) uses: appleboy/ssh-action@v1 >>>>>>> ff702cf (fix test deploy) with: From 82fb31ca263677a4d031e202e189971837e76059 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:21:20 -0500 Subject: [PATCH 28/41] what --- .github/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5abd3f0..72a3d10 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,8 +38,12 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} +<<<<<<< HEAD <<<<<<< HEAD script: ./deploy.sh ======= script: whoami >>>>>>> d2136d9 (add test deploy workflow) +======= + script: ls +>>>>>>> 971f76f (what) From dd945ab33aee2ae8a33a61141202955dd5f04e02 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:26:26 -0500 Subject: [PATCH 29/41] modify deploy script to actually deploy on dev --- .github/workflows/deploy-dev.yml | 52 ++++++++++++++++++++++++++++++++ .github/workflows/deploy.yml | 49 ------------------------------ 2 files changed, 52 insertions(+), 49 deletions(-) delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 0ba2845..8ddaf49 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,4 +1,26 @@ +<<<<<<< HEAD name: Deploy to dev server (https://dev.yip.cat) +======= +<<<<<<<< HEAD:.github/workflows/deploy.yml +<<<<<<< HEAD +name: Deploy to main server (https://yip.cat) +on: + push: + branches: [ main ] + +jobs: + deploy: + name: deploy + runs-on: ubuntu-latest + steps: + - name: Execute remote SSH commands using SSH key + uses: appleboy/ssh-action@v1 +======= +name: test for deploy action +======== +name: Deploy to dev server (https://dev.yip.cat) +>>>>>>>> 85e314a (modify deploy script to actually deploy on dev):.github/workflows/deploy-dev.yml +>>>>>>> 85e314a (modify deploy script to actually deploy on dev) on: push: branches: [ dev ] @@ -8,11 +30,41 @@ jobs: name: deploy-dev runs-on: ubuntu-latest steps: +<<<<<<< HEAD + - name: Execute remote SSH commands using SSH key + uses: appleboy/ssh-action@v1 +======= +<<<<<<< HEAD + - name: deploy +<<<<<<< HEAD + uses: appleboy/ssh-action@master +>>>>>>> d2136d9 (add test deploy workflow) +======= +======= - name: Execute remote SSH commands using SSH key +>>>>>>> f26a8b9 (please.) uses: appleboy/ssh-action@v1 +>>>>>>> ff702cf (fix test deploy) +>>>>>>> 85e314a (modify deploy script to actually deploy on dev) with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} +<<<<<<< HEAD script: ./deploy-dev.sh +======= +<<<<<<<< HEAD:.github/workflows/deploy.yml +<<<<<<< HEAD +<<<<<<< HEAD + script: ./deploy.sh +======= + script: whoami +>>>>>>> d2136d9 (add test deploy workflow) +======= + script: ls +>>>>>>> 971f76f (what) +======== + script: deploy-dev.sh +>>>>>>>> 85e314a (modify deploy script to actually deploy on dev):.github/workflows/deploy-dev.yml +>>>>>>> 85e314a (modify deploy script to actually deploy on dev) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 72a3d10..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,49 +0,0 @@ -<<<<<<< HEAD -name: Deploy to main server (https://yip.cat) -on: - push: - branches: [ main ] - -jobs: - deploy: - name: deploy - runs-on: ubuntu-latest - steps: - - name: Execute remote SSH commands using SSH key - uses: appleboy/ssh-action@v1 -======= -name: test for deploy action -on: - push: - branches: [ dev ] - -jobs: - deploy: - name: deploy - runs-on: ubuntu-latest - steps: -<<<<<<< HEAD - - name: deploy -<<<<<<< HEAD - uses: appleboy/ssh-action@master ->>>>>>> d2136d9 (add test deploy workflow) -======= -======= - - name: Execute remote SSH commands using SSH key ->>>>>>> f26a8b9 (please.) - uses: appleboy/ssh-action@v1 ->>>>>>> ff702cf (fix test deploy) - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.KEY }} - port: ${{ secrets.PORT }} -<<<<<<< HEAD -<<<<<<< HEAD - script: ./deploy.sh -======= - script: whoami ->>>>>>> d2136d9 (add test deploy workflow) -======= - script: ls ->>>>>>> 971f76f (what) From 45208c5130ade546317d0d529f1a3aa139ce2cbe Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:27:01 -0500 Subject: [PATCH 30/41] i am an idiot --- .github/workflows/deploy-dev.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 8ddaf49..ab01247 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -51,6 +51,7 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} +<<<<<<< HEAD <<<<<<< HEAD script: ./deploy-dev.sh ======= @@ -68,3 +69,6 @@ jobs: script: deploy-dev.sh >>>>>>>> 85e314a (modify deploy script to actually deploy on dev):.github/workflows/deploy-dev.yml >>>>>>> 85e314a (modify deploy script to actually deploy on dev) +======= + script: ./deploy-dev.sh +>>>>>>> b1f3a9d (i am an idiot) From 62d3cefa884b09f5062b14e852802a7b57c75f68 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 20:55:54 -0500 Subject: [PATCH 31/41] feat: remove config classes, automatically load .env opt to load relevant config data from the .env rather than classes. update .env.example with `QUART_TESTING` field --- __init__.py | 6 ++++++ src/config/__init__.py | 3 --- uv.lock | 6 ++++++ 3 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 src/config/__init__.py diff --git a/__init__.py b/__init__.py index 28f53ca..d956a07 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,12 @@ from dotenv import load_dotenv +<<<<<<< HEAD +======= +from dotenv import load_dotenv + +from src.about import is_dev +>>>>>>> a5eb571 (feat: remove config classes, automatically load .env) from src.app import create_app load_dotenv() diff --git a/src/config/__init__.py b/src/config/__init__.py deleted file mode 100644 index ccb12c1..0000000 --- a/src/config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["Development", "Production"] - -from .config import Development, Production diff --git a/uv.lock b/uv.lock index f9c2f8d..fb25e41 100644 --- a/uv.lock +++ b/uv.lock @@ -872,6 +872,9 @@ wheels = [ [[package]] <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> a5eb571 (feat: remove config classes, automatically load .env) name = "python-dotenv" version = "1.2.1" source = { registry = "https://pypi.org/simple" } @@ -881,8 +884,11 @@ wheels = [ ] [[package]] +<<<<<<< HEAD ======= >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= +>>>>>>> a5eb571 (feat: remove config classes, automatically load .env) name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } From 359541bc767fa37f6da3bc5fe219f9d5317f8528 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:26:54 -0500 Subject: [PATCH 32/41] perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls --- src/blueprints/term/blueprint.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py index 40bd897..473362c 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/term/blueprint.py @@ -46,6 +46,7 @@ async def ls(): searchpath = f"{home}{path}" else: searchpath = f"{cwd}/{path}" +<<<<<<< HEAD <<<<<<< HEAD if not await anyio.Path(searchpath).exists(): return "", 404 @@ -53,11 +54,17 @@ async def ls(): return {k: {"isDir": await anyio.Path(f"{searchpath}/{k}").is_dir()} for k in files} ======= if not os.path.exists(searchpath): +======= + if not await anyio.Path(searchpath).exists(): +>>>>>>> ce4a28c (perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls) return "", 404 - logger.info(os.path.abspath(searchpath)) files = [*os.listdir(searchpath), *links.keys()] +<<<<<<< HEAD return {k: {"isDir": os.path.isdir(f"{searchpath}/{k}")} for k in files} >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= + return {k: {"isDir": await anyio.Path(f"{searchpath}/{k}").is_dir()} for k in files} +>>>>>>> ce4a28c (perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls) def _replace_data(line: str) -> str: @@ -80,12 +87,17 @@ async def cat(): filepath = links[path.lower()] else: filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" +<<<<<<< HEAD <<<<<<< HEAD if not await anyio.Path(filepath).exists(): ======= if not os.path.exists(filepath): >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= + + if not await anyio.Path(filepath).exists(): +>>>>>>> ce4a28c (perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls) return "", 404 async with await anyio.open_file(filepath, encoding="utf-8") as fp: lines = await fp.readlines() @@ -111,12 +123,17 @@ async def cd(): else: cwd = cwd.replace("~", "").rstrip("/") filepath = f"{home}/{cwd + '/' if cwd else ''}{path}" +<<<<<<< HEAD <<<<<<< HEAD if not await anyio.Path(filepath).is_dir(): ======= if not os.path.isdir(filepath): >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= + + if not await anyio.Path(filepath).is_dir(): +>>>>>>> ce4a28c (perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls) return "", 403 return "", 200 @@ -124,6 +141,7 @@ async def cd(): @blueprint.route("/login-text", methods=["GET"]) async def login_text(): commit_hash = "" +<<<<<<< HEAD <<<<<<< HEAD path = anyio.Path(".git/refs/heads/dev") if await path.exists(): @@ -132,6 +150,11 @@ async def login_text(): if os.path.exists(".git/refs/heads/dev"): async with await anyio.open_file(".git/refs/heads/dev") as fp: >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= + path = anyio.Path(".git/refs/heads/dev") + if await path.exists(): + async with await anyio.open_file(path) as fp: +>>>>>>> ce4a28c (perf(foxterm-backend): change blocking `os.path` calls to async `anyio.Path` calls) commit_hash = (await fp.readline()).strip("\n") return ( From 5f4398bfa6c1c98bc823c5d3410ec5c92e0bf60d Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:27:33 -0500 Subject: [PATCH 33/41] docs: add docstrings for middleware, `src.errors`, and `src.responses` --- src/responses/_base_response.py | 6 ++++++ src/responses/_error_response.py | 6 ++++++ src/responses/_success_response.py | 6 ++++++ src/responses/_url_response.py | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/src/responses/_base_response.py b/src/responses/_base_response.py index b9e133d..34f36f3 100644 --- a/src/responses/_base_response.py +++ b/src/responses/_base_response.py @@ -4,6 +4,9 @@ @dataclass(kw_only=True) class FoxtermResponse: <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) """ Base dataclass for all responses from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -12,6 +15,9 @@ class FoxtermResponse: success (bool) """ +<<<<<<< HEAD ======= >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) success: bool diff --git a/src/responses/_error_response.py b/src/responses/_error_response.py index 318732a..c438621 100644 --- a/src/responses/_error_response.py +++ b/src/responses/_error_response.py @@ -6,6 +6,9 @@ @dataclass(kw_only=True) class ErrorResponse(FoxtermResponse): <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) """ Base error response from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -15,7 +18,10 @@ class ErrorResponse(FoxtermResponse): error (str) """ +<<<<<<< HEAD ======= >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) success: bool = False error: str diff --git a/src/responses/_success_response.py b/src/responses/_success_response.py index 4bde05e..e5d0b53 100644 --- a/src/responses/_success_response.py +++ b/src/responses/_success_response.py @@ -6,6 +6,9 @@ @dataclass(kw_only=True) class SuccessResponse(FoxtermResponse): <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) """ Base success response from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -14,6 +17,9 @@ class SuccessResponse(FoxtermResponse): success (bool) = True """ +<<<<<<< HEAD ======= >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) success: bool = True diff --git a/src/responses/_url_response.py b/src/responses/_url_response.py index 41a088e..b7237f7 100644 --- a/src/responses/_url_response.py +++ b/src/responses/_url_response.py @@ -6,6 +6,9 @@ @dataclass(kw_only=True) class URLResponse(SuccessResponse): <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) """ Dataclass used to return a URL from the Foxterm backend. Each kwarg is a key in the response's JSON object. @@ -17,6 +20,9 @@ class URLResponse(SuccessResponse): url (str) """ +<<<<<<< HEAD ======= >>>>>>> 15f0347 (add responses/ folder, move blueprints into dedicated blueprint.py file) +======= +>>>>>>> ddaec17 (docs: add docstrings for middleware, `src.errors`, and `src.responses`) url: str From eeff8ed71f0272521002121dde6205e98ce45640 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 21:48:44 -0500 Subject: [PATCH 34/41] feat(server): add `POR`T environment variable, change binding from --- __init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__init__.py b/__init__.py index d956a07..97cd1e7 100644 --- a/__init__.py +++ b/__init__.py @@ -3,12 +3,15 @@ from dotenv import load_dotenv +<<<<<<< HEAD <<<<<<< HEAD ======= from dotenv import load_dotenv from src.about import is_dev >>>>>>> a5eb571 (feat: remove config classes, automatically load .env) +======= +>>>>>>> 55956b5 (feat(server): add `POR`T environment variable, change binding from) from src.app import create_app load_dotenv() From afe65f9066ffa957bdaf2ccea919c9738aebff6e Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:13:27 -0500 Subject: [PATCH 35/41] ops: add main branch deploy script --- .github/workflows/deploy.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2a8aca8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,18 @@ +name: Deploy to main server (https://yip.cat) +on: + push: + branches: [ main ] + +jobs: + deploy-dev: + name: deploy-dev + runs-on: ubuntu-latest + steps: + - name: Execute remote SSH commands using SSH key + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY }} + port: ${{ secrets.PORT }} + script: ./deploy.sh From d252b08394e527b44095261badfda48af0394c53 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:15:51 -0500 Subject: [PATCH 36/41] fix(ops): rename main deploy script from deploy-dev to deploy. oops! --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2a8aca8..34c3966 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,8 +4,8 @@ on: branches: [ main ] jobs: - deploy-dev: - name: deploy-dev + deploy: + name: deploy runs-on: ubuntu-latest steps: - name: Execute remote SSH commands using SSH key From f8b22d3662bde6c7f0152fc5f932f6ed66e7f69d Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:42:36 -0500 Subject: [PATCH 37/41] docs(auth.permissions) --- src/auth/permissions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/auth/permissions.py b/src/auth/permissions.py index d668090..9320c66 100644 --- a/src/auth/permissions.py +++ b/src/auth/permissions.py @@ -7,6 +7,14 @@ class Permissions: @staticmethod def sort() -> list: + """Sorts a list of all available permissions by their value, making for + easier indexing without knowing the name. + + Returns: + list: Sorted list of all Permission integer values + """ + # tl;dr: shitty list comprehension that returns only uppercase, non_private variables + # DO NOT DO THIS! THIS IS BAD!!!!! probably. idk im just a fox dont listen to me _current = [ value for name, value in vars(Permissions).items() From 2c967c39c6c9fd3eef5ca087ad7e81f9ff2c19c0 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:43:06 -0500 Subject: [PATCH 38/41] feat(app): add CORS_REGEX environment variable --- .env.example | 3 ++- src/app/app.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index bad4385..48bf608 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ GITHUB_ID='' GITHUB_SECRET='' SERVER_NAME='' DATABASE_PATH='' -PORT=5000 \ No newline at end of file +PORT=5000 +CORS_REGEX=''' \ No newline at end of file diff --git a/src/app/app.py b/src/app/app.py index ae6aadc..5f0638d 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from os import environ from quart import Quart from quart_auth import QuartAuth @@ -22,7 +23,10 @@ def create_app(import_name: str) -> Quart: if app.config["DEBUG"]: app.logger.info("Loading Development configuration...") else: - app = cors(app, allow_origin=re.compile("https://*.yip.cat*")) + app = cors( + app, + allow_origin=re.compile(environ.get("CORS_REGEX", "https://*.yip.cat*")), + ) auth_manager = QuartAuth(app) auth_manager.user_class = User From 39e389e59c74d6f7ed351e678f3bc61a1609fdc8 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:45:48 -0500 Subject: [PATCH 39/41] feat(foxterm-backend): add commit hash to login message for all branches, not just dev --- src/blueprints/term/blueprint.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/term/blueprint.py index 473362c..a7f532c 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/term/blueprint.py @@ -142,8 +142,16 @@ async def cd(): async def login_text(): commit_hash = "" <<<<<<< HEAD +<<<<<<< HEAD <<<<<<< HEAD path = anyio.Path(".git/refs/heads/dev") +======= + if is_dev: + path = anyio.Path(".git/refs/heads/dev") + else: + path = anyio.Path(".git/refs/heads/main") + +>>>>>>> b8fad35 (feat(foxterm-backend): add commit hash to login message for all branches, not just dev) if await path.exists(): async with await anyio.open_file(path) as fp: ======= From f6a9d3b1214b43b4810a1d5ff677a29ab3dc8334 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:48:43 -0500 Subject: [PATCH 40/41] refactor(blueprints): move term (previously foxterm-backend) to `foxterm_backend` --- src/blueprints/foxterm_backend/__init__.py | 2 ++ src/blueprints/{term => foxterm_backend}/blueprint.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/blueprints/foxterm_backend/__init__.py rename src/blueprints/{term => foxterm_backend}/blueprint.py (99%) diff --git a/src/blueprints/foxterm_backend/__init__.py b/src/blueprints/foxterm_backend/__init__.py new file mode 100644 index 0000000..7ef19fe --- /dev/null +++ b/src/blueprints/foxterm_backend/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["blueprint"] +from src.blueprints.foxterm_backend.blueprint import blueprint diff --git a/src/blueprints/term/blueprint.py b/src/blueprints/foxterm_backend/blueprint.py similarity index 99% rename from src/blueprints/term/blueprint.py rename to src/blueprints/foxterm_backend/blueprint.py index a7f532c..ecc61de 100644 --- a/src/blueprints/term/blueprint.py +++ b/src/blueprints/foxterm_backend/blueprint.py @@ -14,7 +14,7 @@ links = {"readme.md": "README.md"} blueprint = Blueprint( - "term", + "foxterm_backend", __name__, template_folder="templates", static_folder="static", From 2ec51a9b0947e033773a907a2ea39a2306bcb403 Mon Sep 17 00:00:00 2001 From: Taggie Date: Tue, 3 Feb 2026 22:49:01 -0500 Subject: [PATCH 41/41] chore: bump version --- src/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/about.py b/src/about.py index 13e7751..c916ed6 100644 --- a/src/about.py +++ b/src/about.py @@ -1,6 +1,6 @@ import os -version = "0.7.1" +version = "0.7.2" if os.path.exists(".git/HEAD"): with open(".git/HEAD") as fp: