54 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
a78d999a61 Fix trailing whitespace and finalize performance improvements
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 51s
Build Docker / BuildImage (push) Successful in 59s
Co-authored-by: Nathanwoodburn <62039630+Nathanwoodburn@users.noreply.github.com>
2025-11-21 11:34:51 +00:00
copilot-swe-agent[bot]
74afdc1b5b Add caching to blog and now blueprints for improved performance
Co-authored-by: Nathanwoodburn <62039630+Nathanwoodburn@users.noreply.github.com>
2025-11-21 11:31:49 +00:00
copilot-swe-agent[bot]
607fdd4d46 Add caching layer for expensive API calls and file operations
Co-authored-by: Nathanwoodburn <62039630+Nathanwoodburn@users.noreply.github.com>
2025-11-21 11:30:23 +00:00
copilot-swe-agent[bot]
ca01b96e80 Initial plan 2025-11-21 11:21:23 +00:00
467faff592 feat: Add now for 20 Nov
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m32s
Build Docker / BuildImage (push) Successful in 2m45s
2025-11-20 12:06:59 +11:00
3791d0be6e fix: Don't show downtime message when in maintainance mode
All checks were successful
Build Docker / BuildImage (push) Successful in 1m3s
Check Code Quality / RuffCheck (push) Successful in 1m4s
2025-11-15 14:59:02 +11:00
06526ca92d feat: Add alternative route for fonts
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m28s
Build Docker / BuildImage (push) Successful in 2m31s
2025-11-15 14:38:12 +11:00
e1ff6e42a5 feat: Add mobile redirect for resume
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m7s
Build Docker / BuildImage (push) Successful in 1m12s
2025-11-14 13:22:38 +11:00
a6670f6533 feat: Add ruff to reqs
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m28s
Build Docker / BuildImage (push) Successful in 2m58s
2025-11-14 13:11:54 +11:00
598cea1ac8 feat: Add pre-commit 2025-11-14 13:11:03 +11:00
9b4febeddb feat: Add nushell to CLI agents 2025-11-06 16:24:27 +11:00
c15a5d5a8b Merge pull request 'Update resume page template to be cleaner' (#6) from feat/new-resume-layout into main
All checks were successful
Build Docker / BuildImage (push) Successful in 2m22s
Check Code Quality / RuffCheck (push) Successful in 2m37s
Reviewed-on: #6
2025-11-05 12:56:29 +11:00
720e59c144 fix: Limit width for html
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m20s
Build Docker / BuildImage (push) Successful in 1m40s
2025-11-04 17:35:41 +11:00
c45a30675c feat: Finish updating resume template
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m12s
Build Docker / BuildImage (push) Successful in 1m24s
2025-11-04 17:24:28 +11:00
9e8e23165e feat: Start on new resume layout 2025-11-04 17:24:28 +11:00
53e05922bf fix: Add curl to dockerfiel for CD healthchecks
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m17s
Build Docker / BuildImage (push) Successful in 2m6s
2025-11-03 13:51:01 +11:00
0be0dad1b2 feat: Add new dockerfile to shrink image
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m57s
Build Docker / BuildImage (push) Successful in 3m42s
2025-11-03 13:44:19 +11:00
f404d55935 feat: Add phone number and city to resume
All checks were successful
Build Docker / BuildImage (push) Successful in 1m5s
Check Code Quality / RuffCheck (push) Successful in 1m8s
2025-11-01 18:03:03 +11:00
009c2b430c feat: Update projects
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m22s
Build Docker / BuildImage (push) Successful in 1m39s
2025-11-01 13:19:15 +11:00
ed96fbcc29 fix: Add curl to Dockerfile for coolify healthchecks
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m19s
Build Docker / BuildImage (push) Successful in 2m11s
2025-11-01 12:49:45 +11:00
4555ef5da2 Merge pull request 'Add new uv python3 manager' (#5) from feat/uv into main
All checks were successful
Build Docker / BuildImage (push) Successful in 1m0s
Check Code Quality / RuffCheck (push) Successful in 1m7s
Reviewed-on: #5
2025-11-01 12:41:28 +11:00
1335a73eb6 fix: Remove requirement.txt to stop nixpacks using pip
All checks were successful
Build Docker / BuildImage (push) Successful in 1m6s
Check Code Quality / RuffCheck (push) Successful in 1m17s
2025-11-01 12:31:47 +11:00
b8f3039629 feat: Update Dockerfile to use new UV system
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m44s
Build Docker / BuildImage (push) Successful in 3m15s
Also fixes error in spotify refreshing
2025-11-01 12:17:25 +11:00
1888160fa5 fix: Update pyproject version to match latest git tag 2025-11-01 11:50:51 +11:00
7dd0f839cf feat: Add initial uv package info 2025-11-01 11:44:44 +11:00
5a0068586a feat: Cleanup resume summary
All checks were successful
Build Docker / BuildImage (push) Successful in 1m3s
Check Code Quality / RuffCheck (push) Successful in 1m8s
2025-10-31 15:16:48 +11:00
8079780c08 Merge pull request 'Refactor blueprints to make them easier to import' (#4) from feat/blueprint_imports into main
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m5s
Build Docker / BuildImage (push) Successful in 1m10s
Reviewed-on: #4
2025-10-30 21:44:46 +11:00
72b8dae35e fix: Update curl tools page to use new demo data
All checks were successful
Build Docker / BuildImage (push) Successful in 1m0s
Check Code Quality / RuffCheck (push) Successful in 1m11s
2025-10-30 21:35:48 +11:00
323ace5775 feat: Remove old demo scripts
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 56s
Build Docker / BuildImage (push) Successful in 1m2s
2025-10-30 21:34:35 +11:00
c2803e372a feat: Dynamically load tool demos and start tracking BSdesign
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 58s
Build Docker / BuildImage (push) Successful in 1m0s
2025-10-30 21:29:27 +11:00
2a9e704f29 feat: Add zellij demo
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 59s
Build Docker / BuildImage (push) Successful in 1m4s
2025-10-30 20:52:20 +11:00
0c490625a9 fix: Add apt update before install python
All checks were successful
Build Docker / BuildImage (push) Successful in 47s
Check Code Quality / RuffCheck (push) Successful in 58s
2025-10-30 20:39:27 +11:00
b9753617ad fix: Remove sudo from action
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 17s
Build Docker / BuildImage (push) Successful in 1m2s
2025-10-30 20:38:09 +11:00
b87d19c5d9 fix: Manually install python3
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 13s
Build Docker / BuildImage (push) Successful in 47s
2025-10-30 20:36:29 +11:00
67e8b4cf7e fix: Try using a new container
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 31s
Build Docker / BuildImage (push) Successful in 58s
2025-10-30 20:33:02 +11:00
bfc6652f29 fix: Install python first
Some checks failed
Build Docker / BuildImage (push) Successful in 55s
Check Code Quality / RuffCheck (push) Failing after 1m35s
2025-10-30 20:28:31 +11:00
38372c0cff fix: Manually install ruff
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 17s
Build Docker / BuildImage (push) Successful in 58s
2025-10-30 20:27:00 +11:00
dd64313006 fix: Set action git url
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 22s
Build Docker / BuildImage (push) Successful in 47s
2025-10-30 20:19:56 +11:00
9e20a6171a feat: Add ruff check
Some checks failed
Check Code Quality / RuffCheck (push) Failing after 25s
Build Docker / BuildImage (push) Successful in 57s
2025-10-30 20:15:10 +11:00
da347fd860 feat: Add max width for Now page contents
All checks were successful
Build Docker / BuildImage (push) Successful in 54s
2025-10-30 20:06:32 +11:00
776b7de753 fix: Replace actions.json in the main server.py
All checks were successful
Build Docker / BuildImage (push) Successful in 1m0s
2025-10-30 19:58:29 +11:00
7b2b3659bb fix: Remove strict slashes from index routes
All checks were successful
Build Docker / BuildImage (push) Successful in 48s
2025-10-30 19:50:03 +11:00
872373dffd feat: Remove strict slashes for now pages 2025-10-30 19:44:01 +11:00
8d832372cd feat: Add curl support for now pages
All checks were successful
Build Docker / BuildImage (push) Successful in 48s
2025-10-30 19:36:43 +11:00
03dae87272 feat: Refactor blueprints to make them easier to import
All checks were successful
Build Docker / BuildImage (push) Successful in 47s
2025-10-30 18:22:21 +11:00
4c654fcb78 feat: Add HTTPie to CLI agents
All checks were successful
Build Docker / BuildImage (push) Successful in 46s
2025-10-30 17:26:06 +11:00
c9542e4af7 fix: Remove X- headers
All checks were successful
Build Docker / BuildImage (push) Successful in 49s
2025-10-30 17:18:50 +11:00
e184375897 feat: Add Posting to CLI tools
All checks were successful
Build Docker / BuildImage (push) Successful in 59s
2025-10-30 17:12:43 +11:00
844f1b52e2 feat: Update isCurl to isCLI to allow more CLI agents 2025-10-30 17:07:20 +11:00
19c51c3665 feat: Add header route for troubleshooting 2025-10-30 17:03:17 +11:00
85ebd460ed feat: Add progress bar to spotify widget
All checks were successful
Build Docker / BuildImage (push) Successful in 1m57s
2025-10-30 11:45:04 +11:00
50879b4f0e feat: Add Vesktop to desktop applications
All checks were successful
Build Docker / BuildImage (push) Successful in 2m0s
2025-10-29 14:59:08 +11:00
6c09923281 feat: Add curl error page and new tests
All checks were successful
Build Docker / BuildImage (push) Successful in 52s
2025-10-28 15:04:21 +11:00
332c408b89 fix: Animation for spotify toggle
All checks were successful
Build Docker / BuildImage (push) Successful in 2m8s
2025-10-28 14:46:09 +11:00
44 changed files with 2868 additions and 1870 deletions

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Bytecode and virtualenvs
__pycache__/
*.pyc
*.pyo
.venv/
.vscode/
.vs/
.ruff_check/
.env
# Pycache in subdirectories
**/__pycache__/
**/*.pyc
**/*.pyo
# Git and CI
.git/
.gitea/
testing/
tests/
# Build and docs
Dockerfile
NathanWoodburn.bsdesign
LICENSE.txt
README.md
# Development caches
*.tmp
*.log

View File

@@ -0,0 +1,18 @@
name: Check Code Quality
run-name: Ruff CI
on:
push:
jobs:
RuffCheck:
runs-on: [ubuntu-latest, amd]
steps:
- uses: actions/checkout@v2
- name: Set up Python
run: |
apt update
apt install -y python3 python3-pip
- name: Install Ruff
run: pip install ruff
- name: Run Ruff
run: ruff check .

16
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.9.8
hooks:
- id: uv-lock
- id: uv-export
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
hooks:
# Run the linter.
- id: ruff-check
# Run the formatter.
- id: ruff-format

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

View File

@@ -1,18 +1,62 @@
FROM --platform=$BUILDPLATFORM python:3.13-alpine AS builder # syntax=docker/dockerfile:1
### Build stage ###
FROM python:3.13-alpine AS build
# Install build dependencies for Pillow and other native wheels
RUN apk add --no-cache \
build-base \
jpeg-dev zlib-dev freetype-dev
# Copy uv (fast Python package manager)
COPY --from=ghcr.io/astral-sh/uv:0.8.21 /uv /uvx /bin/
RUN apk add curl
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY requirements.txt /app # Install dependencies into a virtual environment
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/uv \
python3 -m pip install -r requirements.txt uv sync --locked
COPY . /app # Copy only app source files
COPY blueprints blueprints
COPY main.py server.py curl.py tools.py mail.py ./
COPY templates templates
COPY data data
COPY pwa pwa
COPY .well-known .well-known
# Add mount point for data volume # Clean up caches and pycache
# VOLUME /data RUN rm -rf /root/.cache/uv
RUN find . -type d -name "__pycache__" -exec rm -rf {} +
ENTRYPOINT ["python3"]
CMD ["main.py"]
FROM builder AS dev-envs ### Runtime stage ###
FROM python:3.13-alpine AS runtime
ENV PATH="/app/.venv/bin:$PATH"
# Create non-root user
RUN addgroup -g 1001 appgroup && \
adduser -D -u 1001 -G appgroup -h /app appuser
WORKDIR /app
RUN apk add --no-cache curl
# Copy only whats needed for runtime
COPY --from=build --chown=appuser:appgroup /app/.venv /app/.venv
COPY --from=build --chown=appuser:appgroup /app/blueprints /app/blueprints
COPY --from=build --chown=appuser:appgroup /app/templates /app/templates
COPY --from=build --chown=appuser:appgroup /app/data /app/data
COPY --from=build --chown=appuser:appgroup /app/pwa /app/pwa
COPY --from=build --chown=appuser:appgroup /app/.well-known /app/.well-known
COPY --from=build --chown=appuser:appgroup /app/main.py /app/
COPY --from=build --chown=appuser:appgroup /app/server.py /app/
COPY --from=build --chown=appuser:appgroup /app/curl.py /app/
COPY --from=build --chown=appuser:appgroup /app/tools.py /app/
COPY --from=build --chown=appuser:appgroup /app/mail.py /app/
USER appuser
EXPOSE 5000
ENTRYPOINT ["python3", "main.py"]

BIN
NathanWoodburn.bsdesign Normal file

Binary file not shown.

View File

@@ -3,10 +3,10 @@ import os
from cloudflare import Cloudflare from cloudflare import Cloudflare
from tools import json_response from tools import json_response
acme_bp = Blueprint('acme', __name__) app = Blueprint('acme', __name__)
@acme_bp.route("/hnsdoh-acme", methods=["POST"]) @app.route("/hnsdoh-acme", methods=["POST"])
def post(): def post():
# Get the TXT record from the request # Get the TXT record from the request
if not request.is_json or not request.json: if not request.is_json or not request.json:

View File

@@ -5,9 +5,10 @@ import requests
import re import re
from mail import sendEmail from mail import sendEmail
from tools import getClientIP, getGitCommit, json_response, parse_date, get_tools_data from tools import getClientIP, getGitCommit, json_response, parse_date, get_tools_data
from blueprints.sol import sol_bp from blueprints import sol
from dateutil import parser as date_parser from dateutil import parser as date_parser
from blueprints.spotify import get_spotify_track from blueprints.spotify import get_spotify_track
from cache_helper import get_nc_config, get_git_latest_activity
# Constants # Constants
HTTP_OK = 200 HTTP_OK = 200
@@ -17,21 +18,13 @@ HTTP_NOT_FOUND = 404
HTTP_UNSUPPORTED_MEDIA = 415 HTTP_UNSUPPORTED_MEDIA = 415
HTTP_SERVER_ERROR = 500 HTTP_SERVER_ERROR = 500
api_bp = Blueprint('api', __name__) app = Blueprint('api', __name__, url_prefix='/api/v1')
# Register solana blueprint # Register solana blueprint
api_bp.register_blueprint(sol_bp) app.register_blueprint(sol.app)
# Load configuration
NC_CONFIG = requests.get(
"https://cloud.woodburn.au/s/4ToXgFe3TnnFcN7/download/website-conf.json"
).json()
if 'time-zone' not in NC_CONFIG:
NC_CONFIG['time-zone'] = 10
@api_bp.route("/") @app.route("/", strict_slashes=False)
@api_bp.route("/help") @app.route("/help")
def help(): def help():
"""Provide API documentation and help.""" """Provide API documentation and help."""
return jsonify({ return jsonify({
@@ -40,7 +33,6 @@ def help():
"/time": "Get the current time", "/time": "Get the current time",
"/timezone": "Get the current timezone", "/timezone": "Get the current timezone",
"/message": "Get the message from the config", "/message": "Get the message from the config",
"/ip": "Get your IP address",
"/project": "Get the current project from git", "/project": "Get the current project from git",
"/version": "Get the current version of the website", "/version": "Get the current version of the website",
"/page_date?url=URL&verbose=BOOL": "Get the last modified date of a webpage (verbose is optional, default false)", "/page_date?url=URL&verbose=BOOL": "Get the last modified date of a webpage (verbose is optional, default false)",
@@ -48,6 +40,8 @@ def help():
"/playing": "Get the currently playing Spotify track", "/playing": "Get the currently playing Spotify track",
"/status": "Just check if the site is up", "/status": "Just check if the site is up",
"/ping": "Just check if the site is up", "/ping": "Just check if the site is up",
"/ip": "Get your IP address",
"/headers": "Get your request headers",
"/help": "Get this help message" "/help": "Get this help message"
}, },
"base_url": "/api/v1", "base_url": "/api/v1",
@@ -56,54 +50,57 @@ def help():
"status": HTTP_OK "status": HTTP_OK
}) })
@api_bp.route("/status") @app.route("/status")
@api_bp.route("/ping") @app.route("/ping")
def status(): def status():
return json_response(request, "200 OK", HTTP_OK) return json_response(request, "200 OK", HTTP_OK)
@api_bp.route("/version") @app.route("/version")
def version(): def version():
"""Get the current version of the website.""" """Get the current version of the website."""
return jsonify({"version": getGitCommit()}) return jsonify({"version": getGitCommit()})
@api_bp.route("/time") @app.route("/time")
def time(): def time():
"""Get the current time in the configured timezone.""" """Get the current time in the configured timezone."""
timezone_offset = datetime.timedelta(hours=NC_CONFIG["time-zone"]) nc_config = get_nc_config()
timezone_offset = datetime.timedelta(hours=nc_config["time-zone"])
timezone = datetime.timezone(offset=timezone_offset) timezone = datetime.timezone(offset=timezone_offset)
current_time = datetime.datetime.now(tz=timezone) current_time = datetime.datetime.now(tz=timezone)
return jsonify({ return jsonify({
"timestring": current_time.strftime("%A, %B %d, %Y %I:%M %p"), "timestring": current_time.strftime("%A, %B %d, %Y %I:%M %p"),
"timestamp": current_time.timestamp(), "timestamp": current_time.timestamp(),
"timezone": NC_CONFIG["time-zone"], "timezone": nc_config["time-zone"],
"timeISO": current_time.isoformat(), "timeISO": current_time.isoformat(),
"ip": getClientIP(request), "ip": getClientIP(request),
"status": HTTP_OK "status": HTTP_OK
}) })
@api_bp.route("/timezone") @app.route("/timezone")
def timezone(): def timezone():
"""Get the current timezone setting.""" """Get the current timezone setting."""
nc_config = get_nc_config()
return jsonify({ return jsonify({
"timezone": NC_CONFIG["time-zone"], "timezone": nc_config["time-zone"],
"ip": getClientIP(request), "ip": getClientIP(request),
"status": HTTP_OK "status": HTTP_OK
}) })
@api_bp.route("/message") @app.route("/message")
def message(): def message():
"""Get the message from the configuration.""" """Get the message from the configuration."""
nc_config = get_nc_config()
return jsonify({ return jsonify({
"message": NC_CONFIG["message"], "message": nc_config["message"],
"ip": getClientIP(request), "ip": getClientIP(request),
"status": HTTP_OK "status": HTTP_OK
}) })
@api_bp.route("/ip") @app.route("/ip")
def ip(): def ip():
"""Get the client's IP address.""" """Get the client's IP address."""
return jsonify({ return jsonify({
@@ -112,7 +109,7 @@ def ip():
}) })
@api_bp.route("/email", methods=["POST"]) @app.route("/email", methods=["POST"])
def email_post(): def email_post():
"""Send an email via the API (requires API key).""" """Send an email via the API (requires API key)."""
# Verify json # Verify json
@@ -134,30 +131,19 @@ def email_post():
return sendEmail(data) return sendEmail(data)
@api_bp.route("/project") @app.route("/project")
def project(): def project():
"""Get information about the current git project.""" """Get information about the current git project."""
gitinfo = { git = get_git_latest_activity()
"website": None, repo_name = git["repo"]["name"].lower()
}
try:
git = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1",
headers={"Authorization": os.getenv("git_token")},
)
git = git.json()
git = git[0]
repo_name = git["repo"]["name"]
repo_name = repo_name.lower()
repo_description = git["repo"]["description"] repo_description = git["repo"]["description"]
gitinfo["name"] = repo_name
gitinfo["description"] = repo_description gitinfo = {
gitinfo["url"] = git["repo"]["html_url"] "name": repo_name,
if "website" in git["repo"]: "description": repo_description,
gitinfo["website"] = git["repo"]["website"] "url": git["repo"]["html_url"],
except Exception as e: "website": git["repo"].get("website"),
print(f"Error getting git data: {e}") }
return json_response(request, "500 Internal Server Error", HTTP_SERVER_ERROR)
return jsonify({ return jsonify({
"repo_name": repo_name, "repo_name": repo_name,
@@ -167,7 +153,7 @@ def project():
"status": HTTP_OK "status": HTTP_OK
}) })
@api_bp.route("/tools") @app.route("/tools")
def tools(): def tools():
"""Get a list of tools used by Nathan Woodburn.""" """Get a list of tools used by Nathan Woodburn."""
try: try:
@@ -176,14 +162,9 @@ def tools():
print(f"Error getting tools data: {e}") print(f"Error getting tools data: {e}")
return json_response(request, "500 Internal Server Error", HTTP_SERVER_ERROR) return json_response(request, "500 Internal Server Error", HTTP_SERVER_ERROR)
# Remove demo and move demo_url to demo
for tool in tools:
if "demo_url" in tool:
tool["demo"] = tool.pop("demo_url")
return json_response(request, {"tools": tools}, HTTP_OK) return json_response(request, {"tools": tools}, HTTP_OK)
@api_bp.route("/playing") @app.route("/playing")
def playing(): def playing():
"""Get the currently playing Spotify track.""" """Get the currently playing Spotify track."""
track_info = get_spotify_track() track_info = get_spotify_track()
@@ -191,7 +172,31 @@ def playing():
return json_response(request, track_info, HTTP_OK) return json_response(request, track_info, HTTP_OK)
return json_response(request, {"spotify": track_info}, HTTP_OK) return json_response(request, {"spotify": track_info}, HTTP_OK)
@api_bp.route("/page_date")
@app.route("/headers")
def headers():
"""Get the request headers."""
headers = dict(request.headers)
# For each header, convert list-like headers to lists
toremove = []
for key, _ in headers.items():
# If header is like X- something
if key.startswith("X-"):
# Remove from headers
toremove.append(key)
for key in toremove:
headers.pop(key)
return jsonify({
"headers": headers,
"ip": getClientIP(request),
"status": HTTP_OK
})
@app.route("/page_date")
def page_date(): def page_date():
url = request.args.get("url") url = request.args.get("url")
if not url: if not url:

View File

@@ -3,11 +3,13 @@ from flask import Blueprint, render_template, request, jsonify
import markdown import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re import re
from tools import isCurl, getClientIP, getHandshakeScript from functools import lru_cache
from tools import isCLI, getClientIP, getHandshakeScript
blog_bp = Blueprint('blog', __name__) app = Blueprint('blog', __name__, url_prefix='/blog')
@lru_cache(maxsize=32)
def list_page_files(): def list_page_files():
blog_pages = os.listdir("data/blog") blog_pages = os.listdir("data/blog")
# Sort pages by modified time, newest first # Sort pages by modified time, newest first
@@ -21,28 +23,43 @@ def list_page_files():
return blog_pages return blog_pages
def render_page(date, handshake_scripts=None): @lru_cache(maxsize=64)
# Convert md to html def get_blog_content(date):
"""Get and cache blog content."""
if not os.path.exists(f"data/blog/{date}.md"): if not os.path.exists(f"data/blog/{date}.md"):
return render_template("404.html"), 404 return None
with open(f"data/blog/{date}.md", "r") as f: with open(f"data/blog/{date}.md", "r") as f:
content = f.read() return f.read()
# Get the title from the file name
title = date.removesuffix(".md").replace("_", " ")
# Convert the md to html @lru_cache(maxsize=64)
content = markdown.markdown( def render_markdown_to_html(content):
"""Convert markdown to HTML with caching."""
html = markdown.markdown(
content, extensions=['sane_lists', 'codehilite', 'fenced_code']) content, extensions=['sane_lists', 'codehilite', 'fenced_code'])
# Add target="_blank" to all links # Add target="_blank" to all links
content = content.replace('<a href="', '<a target="_blank" href="') html = html.replace('<a href="', '<a target="_blank" href="')
html = html.replace("<h4", "<h4 style='margin-bottom:0px;'")
html = fix_numbered_lists(html)
return html
content = content.replace("<h4", "<h4 style='margin-bottom:0px;'")
content = fix_numbered_lists(content) def render_page(date, handshake_scripts=None):
# Get cached content
content = get_blog_content(date)
if content is None:
return render_template("404.html"), 404
# Get the title from the file name
title = date.removesuffix(".md").replace("_", " ")
# Convert the md to html (cached)
html_content = render_markdown_to_html(content)
return render_template( return render_template(
"blog/template.html", "blog/template.html",
title=title, title=title,
content=content, content=html_content,
handshake_scripts=handshake_scripts, handshake_scripts=handshake_scripts,
) )
@@ -108,9 +125,9 @@ def render_home(handshake_scripts: str | None = None):
) )
@blog_bp.route("/") @app.route("/", strict_slashes=False)
def index(): def index():
if not isCurl(request): if not isCLI(request):
return render_home(handshake_scripts=getHandshakeScript(request.host)) return render_home(handshake_scripts=getHandshakeScript(request.host))
# Get a list of pages # Get a list of pages
@@ -129,17 +146,16 @@ def index():
}), 200 }), 200
@blog_bp.route("/<path:path>") @app.route("/<path:path>")
def path(path): def path(path):
if not isCurl(request): if not isCLI(request):
return render_page(path, handshake_scripts=getHandshakeScript(request.host)) return render_page(path, handshake_scripts=getHandshakeScript(request.host))
# Convert md to html # Get cached content
if not os.path.exists(f"data/blog/{path}.md"): content = get_blog_content(path)
if content is None:
return render_template("404.html"), 404 return render_template("404.html"), 404
with open(f"data/blog/{path}.md", "r") as f:
content = f.read()
# Get the title from the file name # Get the title from the file name
title = path.replace("_", " ") title = path.replace("_", " ")
return jsonify({ return jsonify({
@@ -152,13 +168,11 @@ def path(path):
}), 200 }), 200
@blog_bp.route("/<path:path>.md") @app.route("/<path:path>.md")
def path_md(path): def path_md(path):
if not os.path.exists(f"data/blog/{path}.md"): content = get_blog_content(path)
if content is None:
return render_template("404.html"), 404 return render_template("404.html"), 404
with open(f"data/blog/{path}.md", "r") as f:
content = f.read()
# Return the raw markdown file # Return the raw markdown file
return content, 200, {'Content-Type': 'text/plain; charset=utf-8'} return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}

View File

@@ -1,12 +1,17 @@
from flask import Blueprint, render_template, make_response, request, jsonify from flask import Blueprint, render_template, make_response, request, jsonify
import datetime import datetime
import os import os
from tools import getHandshakeScript from functools import lru_cache
from tools import getHandshakeScript, error_response, isCLI
from curl import get_header, MAX_WIDTH
from bs4 import BeautifulSoup
import re
# Create blueprint # Create blueprint
now_bp = Blueprint('now', __name__) app = Blueprint('now', __name__, url_prefix='/now')
@lru_cache(maxsize=16)
def list_page_files(): def list_page_files():
now_pages = os.listdir("templates/now") now_pages = os.listdir("templates/now")
now_pages = [ now_pages = [
@@ -16,12 +21,14 @@ def list_page_files():
return now_pages return now_pages
@lru_cache(maxsize=16)
def list_dates(): def list_dates():
now_pages = list_page_files() now_pages = list_page_files()
now_dates = [page.split(".")[0] for page in now_pages] now_dates = [page.split(".")[0] for page in now_pages]
return now_dates return now_dates
@lru_cache(maxsize=8)
def get_latest_date(formatted=False): def get_latest_date(formatted=False):
if formatted: if formatted:
date = list_dates()[0] date = list_dates()[0]
@@ -44,27 +51,115 @@ def render(date, handshake_scripts=None):
date = date.removesuffix(".html") date = date.removesuffix(".html")
if date not in list_dates(): if date not in list_dates():
return render_template("404.html"), 404 return error_response(request)
date_formatted = datetime.datetime.strptime(date, "%y_%m_%d") date_formatted = datetime.datetime.strptime(date, "%y_%m_%d")
date_formatted = date_formatted.strftime("%A, %B %d, %Y") date_formatted = date_formatted.strftime("%A, %B %d, %Y")
return render_template(f"now/{date}.html", DATE=date_formatted, handshake_scripts=handshake_scripts) return render_template(f"now/{date}.html", DATE=date_formatted, handshake_scripts=handshake_scripts)
def render_curl(date=None):
# If the date is not available, render the latest page
if date is None:
date = get_latest_date()
@now_bp.route("/") # Remove .html if present
date = date.removesuffix(".html")
if date not in list_dates():
return error_response(request)
# Format the date nicely
date_formatted = datetime.datetime.strptime(date, "%y_%m_%d")
date_formatted = date_formatted.strftime("%A, %B %d, %Y")
# Load HTML
with open(f"templates/now/{date}.html", "r", encoding="utf-8") as f:
raw_html = f.read().replace("{{ date }}", date_formatted)
soup = BeautifulSoup(raw_html, 'html.parser')
posts = []
# Find divs matching your pattern
divs = soup.find_all("div", style=re.compile(r"max-width:\s*700px", re.IGNORECASE))
if not divs:
return error_response(request, message="No content found for CLI rendering.")
for div in divs:
# header could be h1/h2/h3 inside the div
header_tag = div.find(["h1", "h2", "h3"]) # type: ignore
# content is usually one or more <p> tags inside the div
p_tags = div.find_all("p") # type: ignore
if header_tag and p_tags:
header_text = header_tag.get_text(strip=True) # type: ignore
content_lines = []
for p in p_tags:
# Extract text
text = p.get_text(strip=False)
# Extract any <a> links in the paragraph
links = [a.get("href") for a in p.find_all("a", href=True)] # type: ignore
# Set max width for text wrapping
# Wrap text manually
wrapped_lines = []
for line in text.splitlines():
while len(line) > MAX_WIDTH:
# Find last space within max_width
split_at = line.rfind(' ', 0, MAX_WIDTH)
if split_at == -1:
split_at = MAX_WIDTH
wrapped_lines.append(line[:split_at].rstrip())
line = line[split_at:].lstrip()
wrapped_lines.append(line)
text = "\n".join(wrapped_lines)
if links:
text += "\nLinks: " + ", ".join(links) # type: ignore
content_lines.append(text)
content_text = "\n\n".join(content_lines)
posts.append({"header": header_text, "content": content_text})
# Build final response
response = ""
for post in posts:
response += f"{post['header']}\n\n{post['content']}\n\n"
return render_template("now.ascii", date=date_formatted, content=response, header=get_header())
@app.route("/", strict_slashes=False)
def index(): def index():
if isCLI(request):
return render_curl()
return render_latest(handshake_scripts=getHandshakeScript(request.host)) return render_latest(handshake_scripts=getHandshakeScript(request.host))
@now_bp.route("/<path:path>") @app.route("/<path:path>")
def path(path): def path(path):
if isCLI(request):
return render_curl(path)
return render(path, handshake_scripts=getHandshakeScript(request.host)) return render(path, handshake_scripts=getHandshakeScript(request.host))
@now_bp.route("/old") @app.route("/old", strict_slashes=False)
@now_bp.route("/old/")
def old(): def old():
now_dates = list_dates()[1:] now_dates = list_dates()[1:]
if isCLI(request):
response = ""
for date in now_dates:
link = date
date_fmt = datetime.datetime.strptime(date, "%y_%m_%d")
date_fmt = date_fmt.strftime("%A, %B %d, %Y")
response += f"{date_fmt} - /now/{link}\n"
return render_template("now.ascii", date="Old Now Pages", content=response, header=get_header())
html = '<ul class="list-group">' html = '<ul class="list-group">'
html += f'<a style="text-decoration:none;" href="/now"><li class="list-group-item" style="background-color:#000000;color:#ffffff;">{get_latest_date(True)}</li></a>' html += f'<a style="text-decoration:none;" href="/now"><li class="list-group-item" style="background-color:#000000;color:#ffffff;">{get_latest_date(True)}</li></a>'
@@ -80,9 +175,9 @@ def old():
) )
@now_bp.route("/now.rss") @app.route("/now.rss")
@now_bp.route("/now.xml") @app.route("/now.xml")
@now_bp.route("/rss.xml") @app.route("/rss.xml")
def rss(): def rss():
host = "https://" + request.host host = "https://" + request.host
if ":" in request.host: if ":" in request.host:
@@ -99,7 +194,7 @@ def rss():
return make_response(rss, 200, {"Content-Type": "application/rss+xml"}) return make_response(rss, 200, {"Content-Type": "application/rss+xml"})
@now_bp.route("/now.json") @app.route("/now.json")
def json(): def json():
now_pages = list_page_files() now_pages = list_page_files()
host = "https://" + request.host host = "https://" + request.host

View File

@@ -2,9 +2,9 @@ from flask import Blueprint, make_response, request
from tools import error_response from tools import error_response
import requests import requests
podcast_bp = Blueprint('podcast', __name__) app = Blueprint('podcast', __name__)
@podcast_bp.route("/ID1") @app.route("/ID1")
def index(): def index():
# Proxy to ID1 url # Proxy to ID1 url
req = requests.get("https://podcasts.c.woodburn.au/ID1") req = requests.get("https://podcasts.c.woodburn.au/ID1")
@@ -16,7 +16,7 @@ def index():
) )
@podcast_bp.route("/ID1/") @app.route("/ID1/")
def contents(): def contents():
# Proxy to ID1 url # Proxy to ID1 url
req = requests.get("https://podcasts.c.woodburn.au/ID1/") req = requests.get("https://podcasts.c.woodburn.au/ID1/")
@@ -27,7 +27,7 @@ def contents():
) )
@podcast_bp.route("/ID1/<path:path>") @app.route("/ID1/<path:path>")
def path(path): def path(path):
# Proxy to ID1 url # Proxy to ID1 url
req = requests.get("https://podcasts.c.woodburn.au/ID1/" + path) req = requests.get("https://podcasts.c.woodburn.au/ID1/" + path)
@@ -38,7 +38,7 @@ def path(path):
) )
@podcast_bp.route("/ID1.xml") @app.route("/ID1.xml")
def xml(): def xml():
# Proxy to ID1 url # Proxy to ID1 url
req = requests.get("https://podcasts.c.woodburn.au/ID1.xml") req = requests.get("https://podcasts.c.woodburn.au/ID1.xml")
@@ -49,7 +49,7 @@ def xml():
) )
@podcast_bp.route("/podsync.opml") @app.route("/podsync.opml")
def podsync(): def podsync():
req = requests.get("https://podcasts.c.woodburn.au/podsync.opml") req = requests.get("https://podcasts.c.woodburn.au/podsync.opml")
if req.status_code != 200: if req.status_code != 200:

View File

@@ -9,7 +9,7 @@ import binascii
import base64 import base64
import os import os
sol_bp = Blueprint('sol', __name__) app = Blueprint('sol', __name__)
SOLANA_HEADERS = { SOLANA_HEADERS = {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -55,7 +55,7 @@ def get_solana_address() -> str:
raise ValueError("SOLANA_ADDRESS is not set. Please ensure the .well-known/wallets/SOL file exists and contains a valid address.") raise ValueError("SOLANA_ADDRESS is not set. Please ensure the .well-known/wallets/SOL file exists and contains a valid address.")
return str(SOLANA_ADDRESS) return str(SOLANA_ADDRESS)
@sol_bp.route("/donate", methods=["GET", "OPTIONS"]) @app.route("/donate", methods=["GET", "OPTIONS"])
def sol_donate(): def sol_donate():
data = { data = {
"icon": "https://nathan.woodburn.au/assets/img/profile.png", "icon": "https://nathan.woodburn.au/assets/img/profile.png",
@@ -90,7 +90,7 @@ def sol_donate():
return response return response
@sol_bp.route("/donate/<amount>") @app.route("/donate/<amount>")
def sol_donate_amount(amount): def sol_donate_amount(amount):
data = { data = {
"icon": "https://nathan.woodburn.au/assets/img/profile.png", "icon": "https://nathan.woodburn.au/assets/img/profile.png",
@@ -101,7 +101,7 @@ def sol_donate_amount(amount):
return jsonify(data), 200, SOLANA_HEADERS return jsonify(data), 200, SOLANA_HEADERS
@sol_bp.route("/donate/<amount>", methods=["POST"]) @app.route("/donate/<amount>", methods=["POST"])
def sol_donate_post(amount): def sol_donate_post(amount):
if not request.json: if not request.json:

View File

@@ -5,7 +5,7 @@ import requests
import time import time
import base64 import base64
spotify_bp = Blueprint('spotify', __name__) app = Blueprint('spotify', __name__, url_prefix='/spotify')
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
@@ -25,6 +25,10 @@ def refresh_access_token():
"""Refresh Spotify access token when expired.""" """Refresh Spotify access token when expired."""
global ACCESS_TOKEN, TOKEN_EXPIRES global ACCESS_TOKEN, TOKEN_EXPIRES
# If no refresh token, cannot proceed
if not REFRESH_TOKEN:
return None
# If still valid, reuse it # If still valid, reuse it
if ACCESS_TOKEN and time.time() < TOKEN_EXPIRES - 60: if ACCESS_TOKEN and time.time() < TOKEN_EXPIRES - 60:
return ACCESS_TOKEN return ACCESS_TOKEN
@@ -48,7 +52,7 @@ def refresh_access_token():
TOKEN_EXPIRES = time.time() + token_info.get("expires_in", 3600) TOKEN_EXPIRES = time.time() + token_info.get("expires_in", 3600)
return ACCESS_TOKEN return ACCESS_TOKEN
@spotify_bp.route("/login") @app.route("/login")
def login(): def login():
auth_query = ( auth_query = (
f"{SPOTIFY_AUTH_URL}?response_type=code&client_id={CLIENT_ID}" f"{SPOTIFY_AUTH_URL}?response_type=code&client_id={CLIENT_ID}"
@@ -56,7 +60,7 @@ def login():
) )
return redirect(auth_query) return redirect(auth_query)
@spotify_bp.route("/callback") @app.route("/callback")
def callback(): def callback():
code = request.args.get("code") code = request.args.get("code")
if not code: if not code:
@@ -89,41 +93,18 @@ def callback():
print("Refresh Token:", REFRESH_TOKEN) print("Refresh Token:", REFRESH_TOKEN)
return redirect(url_for("spotify.currently_playing")) return redirect(url_for("spotify.currently_playing"))
@spotify_bp.route("/") @app.route("/", strict_slashes=False)
@spotify_bp.route("/currently-playing") @app.route("/playing")
def currently_playing(): def currently_playing():
"""Public endpoint showing your current track.""" """Public endpoint showing your current track."""
token = refresh_access_token() track = get_spotify_track()
if not token:
return json_response(request, {"error": "Failed to refresh access token"}, 500)
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(SPOTIFY_CURRENTLY_PLAYING_URL, headers=headers)
if response.status_code == 204:
return json_response(request, {"message": "Nothing is currently playing."}, 200)
elif response.status_code != 200:
return json_response(request, {"error": "Spotify API error", "status": response.status_code}, response.status_code)
data = response.json()
if not data.get("item"):
return json_response(request, {"message": "Nothing is currently playing."}, 200)
track = {
"song_name": data["item"]["name"],
"artist": ", ".join([artist["name"] for artist in data["item"]["artists"]]),
"album_name": data["item"]["album"]["name"],
"album_art": data["item"]["album"]["images"][0]["url"],
"is_playing": data["is_playing"]
}
return json_response(request, {"spotify":track}, 200) return json_response(request, {"spotify":track}, 200)
def get_spotify_track(): def get_spotify_track():
"""Internal function to get current playing track without HTTP context.""" """Internal function to get current playing track without HTTP context."""
token = refresh_access_token() token = refresh_access_token()
if not token: if not token:
return json_response(request, {"error": "Failed to refresh access token"}, 500) return {"error": "Failed to refresh access token"}
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
response = requests.get(SPOTIFY_CURRENTLY_PLAYING_URL, headers=headers) response = requests.get(SPOTIFY_CURRENTLY_PLAYING_URL, headers=headers)
@@ -137,12 +118,13 @@ def get_spotify_track():
if not data.get("item"): if not data.get("item"):
return {"error": "Nothing is currently playing."} return {"error": "Nothing is currently playing."}
track = { track = {
"song_name": data["item"]["name"], "song_name": data["item"]["name"],
"artist": ", ".join([artist["name"] for artist in data["item"]["artists"]]), "artist": ", ".join([artist["name"] for artist in data["item"]["artists"]]),
"album_name": data["item"]["album"]["name"], "album_name": data["item"]["album"]["name"],
"album_art": data["item"]["album"]["images"][0]["url"], "album_art": data["item"]["album"]["images"][0]["url"],
"is_playing": data["is_playing"] "is_playing": data["is_playing"],
"progress_ms": data.get("progress_ms",0),
"duration_ms": data["item"].get("duration_ms",1)
} }
return track return track

View File

@@ -1,9 +1,9 @@
from flask import Blueprint, request from flask import Blueprint, request
from tools import json_response from tools import json_response
template_bp = Blueprint('template', __name__) app = Blueprint('template', __name__)
@template_bp.route("/") @app.route("/", strict_slashes=False)
def index(): def index():
return json_response(request, "Success", 200) return json_response(request, "Success", 200)

View File

@@ -1,665 +0,0 @@
from flask import Blueprint, request, render_template, session
from tools import json_response, getClientIP
from datetime import datetime
terminal_bp = Blueprint('terminal', __name__)
@terminal_bp.route("/terminal")
def index():
return render_template("terminal.html", user=getClientIP(request))
COMMANDS = {
"help": "Show this help message",
"about": "About this terminal",
"clear": "Clear the terminal",
"echo [text]": "Echo text back",
"whoami": "Display the current user",
"ls": "List directory contents",
"pwd": "Print working directory",
"date": "Show current date and time with optional format",
"cd [path]": "Change directory to path",
"cat [file]": "Display file contents",
"rm [file]": "Remove a file",
"tree [path]": "Display directory tree",
"touch [file]": "Create a new empty file",
"nano [file]": "Edit a file",
"reset": "Reset the terminal session",
"exit": "Exit the terminal session",
}
BOOT_FILES = [
"amd-ucode.img",
"EFI",
"initramfs-linux-fallback.img",
"initramfs-linux.img",
"initramfs-linux-lts-fallback.img",
"initramfs-linux-lts.img",
"intel-ucode.img",
"loader",
"vmlinuz-linux",
"vmlinuz-linux-lts"
]
def get_nodes_in_directory(path: str) -> list[str]:
"""Simulate getting files in a directory for the terminal."""
# If path is valid, get files from session
if session.get("paths", None) is None:
setup_path_session()
# Split path into parts
parts = path.strip("/").split("/")
if not parts or parts == [""]:
return session["paths"]
current_level = session["paths"]
for part in parts:
found = False
for item in current_level:
if item["name"] == part and item["type"] == 0:
current_level = item.get("children", [])
found = True
break
if not found:
return []
return current_level
def setup_path_session():
"""Initialize the session path variables if not already set."""
if "pwd" not in session:
session["pwd"] = f"/home/{getClientIP(request)}"
if "paths" not in session:
binaries = []
for cmd in COMMANDS.keys():
binaries.append({"name": cmd.split()[0], "type": 2, "content": "", "permissions": 1})
boot_files = []
for bin_file in BOOT_FILES:
boot_files.append({"name": bin_file, "type": 1, "content": "", "permissions": 1})
session["paths"] = [
{"name": "home", "type": 0, "children": [
{"name": str(getClientIP(request)), "type": 0, "children": [
{"name": "Readme.txt", "type": 1, "content": "This is a README file.", "permissions": 2},
], "permissions": 2}
], "permissions": 1},
{"name": "bin", "type": 0, "children": binaries, "permissions": 1},
{"name": "boot", "type": 0, "children": boot_files, "permissions": 1},
{"name": "dev", "type": 0, "children": [], "permissions": 1},
{"name": "etc", "type": 0, "children": [], "permissions": 1},
{"name": "lib", "type": 0, "children": [], "permissions": 1},
{"name": "lib64", "type": 0, "children": [], "permissions": 1},
{"name": "opt", "type": 0, "children": [], "permissions": 1},
{"name": "proc", "type": 0, "children": [], "permissions": 1},
{"name": "root", "type": 0, "children": [], "permissions": 1},
{"name": "run", "type": 0, "children": [], "permissions": 1},
{"name": "sbin", "type": 0, "children": [], "permissions": 1},
{"name": "srv", "type": 0, "children": [], "permissions": 1},
{"name": "sys", "type": 0, "children": [], "permissions": 1},
{"name": "tmp", "type": 0, "children": [], "permissions": 1},
{"name": "usr", "type": 0, "children": [], "permissions": 1},
{"name": "var", "type": 0, "children": [], "permissions": 1},
]
def is_valid_path(path: str) -> bool:
"""Check if the given path is valid in the simulated terminal."""
if session.get("paths", None) is None:
setup_path_session()
# Split path into parts
parts = path.strip("/").split("/")
if not parts or parts == [""]:
return True # Root path is valid
current_level = session["paths"]
for part in parts:
found = False
for item in current_level:
if item["name"] == part and item["type"] == 0:
current_level = item.get("children", [])
found = True
break
if not found:
return False
return True
def is_valid_file(path: str) -> bool:
"""Check if the given file exists in the current directory."""
if session.get("paths", None) is None:
setup_path_session()
# Get path parts
parts = path.split("/")
# Get files in the directory
if is_valid_path("/".join(parts[:-1])):
files = get_nodes_in_directory("/".join(parts[:-1]))
for item in files:
if item["name"] == parts[-1] and item["type"] == 1:
return True
return False
def is_valid_binary(path: str) -> bool:
"""Check if the given file exists in the current directory."""
if session.get("paths", None) is None:
setup_path_session()
# Get path parts
parts = path.split("/")
# Get files in the directory
if is_valid_path("/".join(parts[:-1])):
files = get_nodes_in_directory("/".join(parts[:-1]))
for item in files:
if item["name"] == parts[-1] and item["type"] == 2:
return True
return False
def get_node(path: str) -> dict:
"""Get the node (file or directory) at the given path."""
if session.get("paths", None) is None:
setup_path_session()
parts = path.strip("/").split("/")
current_level = session["paths"]
for part in parts:
for item in current_level:
if item["name"] == part:
if part == parts[-1]:
return item
else:
current_level = item.get("children", [])
break
return {}
def build_tree(path: str, prefix: str = "") -> str:
output = ""
files = get_nodes_in_directory(path)
for i, item in enumerate(files):
connector = "└── " if i == len(files) - 1 else "├── "
output += f"{prefix}{connector}{item['name']}\n"
if item["type"] == 0: # Directory
extension = " " if i == len(files) - 1 else ""
output += build_tree(sanitize_path(path + "/" + item["name"]), prefix + extension)
return output
def sanitize_path(path: str) -> str:
"""Sanitize the given path to prevent directory traversal."""
parts = path.strip("/").split("/")
sanitized_parts = []
for part in parts:
if part == "" or part == ".":
continue
elif part == "..":
if sanitized_parts:
sanitized_parts.pop()
else:
sanitized_parts.append(part)
return "/" + "/".join(sanitized_parts)
def remove_node(path: str) -> bool:
"""Remove the node (file or directory) at the given path."""
if session.get("paths", None) is None:
setup_path_session()
parts = path.strip("/").split("/")
current_level = session["paths"]
for i, part in enumerate(parts):
for j, item in enumerate(current_level):
if item["name"] == part:
if i == len(parts) - 1:
# Remove the item
del current_level[j]
# Update the session paths
session["paths"] = session["paths"]
return True
else:
current_level = item.get("children", [])
break
return False
@terminal_bp.route("/terminal/execute/ls", methods=["POST"])
def ls():
data = request.get_json() or {}
args = data.get("args", "")
args = args.split()
# Check if -a flag is provided
# Pop if -a flag from args
all_files = False
if "-a" in args:
all_files = True
args.remove("-a")
elif "--all" in args:
all_files = True
args.remove("--all")
long_format = False
if "-l" in args:
long_format = True
args.remove("-l")
elif "--long" in args:
long_format = True
args.remove("--long")
for arg in args:
if arg.startswith("-") and not arg.startswith("--"):
if "l" in arg:
long_format = True
if "a" in arg:
all_files = True
args.remove(arg)
ip = getClientIP(request)
path = session.get("pwd", f"/home/{ip}")
if args:
path = args[0]
if not path.startswith("/"):
# Relative path
path = sanitize_path(session.get("pwd", f"/home/{ip}") + "/" + path)
if not is_valid_path(path):
# If it is a file or binary, return it
if is_valid_file(path) or is_valid_binary(path):
if long_format:
node = get_node(path)
permissions = node.get("permissions", 0)
perm_str = ""
perm_str += "r" if permissions > 0 else "-"
perm_str += "w" if permissions > 1 else "-"
perm_str += "x" if (node.get("type", 0) == 2 and permissions > 1) else "-"
user = f"{ip}" if path.startswith(f"/home/{ip}") else "root"
output = f"{perm_str} {user} {user} {path.split('/')[-1]}"
return json_response(request, {"output": output}, 200)
return json_response(request, {"output": path.split("/")[-1]}, 200)
return json_response(request, {"output": f"ls: cannot access '{path}': No such file or directory"}, 200)
files = get_nodes_in_directory(path)
output = []
if long_format:
for f in files:
permissions = f.get("permissions", 0)
perm_str = ""
perm_str += "r" if permissions > 0 else "-"
perm_str += "w" if permissions > 1 else "-"
perm_str += "x" if (f.get("type", 0) == 2 and permissions >= 1) else "-"
user = f"{ip}" if path.startswith(f"/home/{ip}") else "root"
output.append(f"{perm_str} {user} {user} {f['name']}")
else:
output = [f["name"] for f in files]
if all_files:
output.insert(0, ".")
output.insert(1, "..")
else:
output = [file for file in output if not file.startswith(".")]
if long_format:
output = "\n".join(output)
else:
output = " ".join(output)
return json_response(request, {"output": output}, 200)
@terminal_bp.route("/terminal/pwd")
def pwd():
if "pwd" not in session:
session["pwd"] = f"/home/{getClientIP(request)}"
pwd = session["pwd"]
if pwd == "/home/" + getClientIP(request):
pwd = "~"
return json_response(request, {"output": pwd, "raw": session["pwd"]}, 200)
@terminal_bp.route("/terminal/execute/cd", methods=["POST"])
def cd():
data = request.get_json() or {}
args = data.get("args", "")
args = args.split()
if not args:
# No path provided, go to home
session["pwd"] = f"/home/{getClientIP(request)}"
output = ""
else:
path = args[0]
# Simulate changing directory
if path == "~":
session["pwd"] = f"/home/{getClientIP(request)}"
output = ""
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
if is_valid_path(path):
session["pwd"] = sanitize_path(path)
output = ""
else:
output = f"bash: cd: {path}: No such file or directory"
return json_response(request, {"output": output}, 200)
def can_write_to_path(path: str) -> bool:
"""Check if the user can write to the given path (must be in their home directory)."""
user_home = f"/home/{getClientIP(request)}"
normalized_path = sanitize_path(path)
return normalized_path.startswith(user_home)
def create_file(path: str, content: str = "") -> bool:
"""Create a new file at the given path."""
if session.get("paths", None) is None:
setup_path_session()
parts = path.strip("/").split("/")
filename = parts[-1]
dir_path = "/".join(parts[:-1])
# Get the directory
if not is_valid_path("/" + dir_path):
return False
# Get nodes in directory
dir_parts = dir_path.strip("/").split("/")
current_level = session["paths"]
for part in dir_parts:
if part == "":
continue
for item in current_level:
if item["name"] == part and item["type"] == 0:
current_level = item.get("children", [])
break
# Check if file already exists
for item in current_level:
if item["name"] == filename:
# Update existing file
item["content"] = content
session.modified = True
return True
# Create new file
new_file = {
"name": filename,
"type": 1,
"content": content,
"permissions": 2
}
current_level.append(new_file)
session.modified = True
return True
def update_file_content(path: str, content: str) -> bool:
"""Update the content of an existing file."""
if session.get("paths", None) is None:
setup_path_session()
parts = path.strip("/").split("/")
current_level = session["paths"]
for i, part in enumerate(parts):
for item in current_level:
if item["name"] == part:
if i == len(parts) - 1:
# Update the file content
if item["type"] == 1:
item["content"] = content
session.modified = True
return True
return False
else:
current_level = item.get("children", [])
break
return False
@terminal_bp.route("/terminal/execute/touch", methods=["POST"])
def touch():
try:
data = request.get_json() or {}
args = data.get("args", "")
args = args.split()
if not args:
return json_response(request, {"output": "touch: missing file operand"}, 200)
path = args[0]
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
# Check if user can write to this path
if not can_write_to_path(path):
return json_response(request, {"output": f"touch: cannot touch '{path}': Permission denied"}, 200)
# Check if file already exists
if is_valid_file(path):
return json_response(request, {"output": f"touch: '{path}': File already exists"}, 200)
# Create the file
if create_file(path, ""):
output = ""
else:
output = f"touch: cannot create '{path}': No such file or directory"
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"touch: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/nano", methods=["POST"])
def nano():
try:
data = request.get_json() or {}
args = data.get("args", "")
# Parse args - format: filename CONTENT content here
parts = args.split(" CONTENT ", 1)
if len(parts) != 2:
return json_response(request, {"output": "Usage: nano [file] CONTENT [text]\nExample: nano test.txt CONTENT Hello World"}, 200)
path = parts[0].strip()
content = parts[1]
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
# Check if user can write to this path
if not can_write_to_path(path):
return json_response(request, {"output": f"nano: cannot write to '{path}': Permission denied"}, 200)
# Check if file exists
if is_valid_file(path):
# Update existing file
node = get_node(path)
if node.get("permissions", 0) < 2:
return json_response(request, {"output": f"nano: cannot write to '{path}': Permission denied"}, 200)
if update_file_content(path, content):
output = ""
else:
output = f"nano: failed to update '{path}'"
else:
# Create new file
if create_file(path, content):
output = f"File '{path}' created successfully"
else:
output = f"nano: cannot create '{path}': No such file or directory"
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"nano: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/cat", methods=["POST"])
def cat():
try:
data = request.get_json() or {}
args = data.get("args", "")
args = args.split()
if not args:
return json_response(request, {"output": "cat: missing file operand"}, 200)
path = args[0]
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
# Check if path is valid
if not is_valid_file(path):
# Check if it is a binary
if is_valid_binary(path):
return json_response(request, {"output": f"cat: {path}: Binary file"}, 200)
return json_response(request, {"output": f"cat: {path}: No such file or directory"}, 200)
output = get_node(path).get("content", "")
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"cat: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/echo", methods=["POST"])
def echo():
try:
data = request.get_json() or {}
args = data.get("args", "")
return json_response(request, {"output": args}, 200)
except Exception as e:
return json_response(request, {"output": f"echo: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/date", methods=["POST"])
def date():
try:
# See if any args are passed
data = request.get_json() or {}
args = data.get("args", "")
# Use arguments to format date if needed
if args:
# If args if --help or -h, show help message
if args in ("--help", "-h"):
output = (
"Usage: date [FORMAT]\n\n"
"Display the current date and time.\n\n"
"FORMAT controls the output. Some common format specifiers:\n"
" %a Abbreviated weekday name (e.g., 'Mon')\n"
" %b Abbreviated month name (e.g., 'Jan')\n"
" %d Day of the month (01 to 31)\n"
" %H Hour (00 to 23)\n"
" %M Minute (00 to 59)\n"
" %S Second (00 to 60)\n"
" %Y Year with century (e.g., 2024)\n\n"
"Example: date '%Y-%m-%d %H:%M:%S'"
)
return json_response(request, {"output": output}, 200)
try:
output = datetime.now().strftime(args)
except Exception:
output = "Invalid date format."
else:
output = datetime.now().strftime("%a %b %d %H:%M:%S %Z %Y")
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"date: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/rm", methods=["POST"])
def rm():
try:
data = request.get_json() or {}
args = data.get("args", "")
args = args.split()
if not args:
return json_response(request, {"output": "rm: missing operand"}, 200)
path = args[0]
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
# Check if user can write to this path
if not can_write_to_path(path):
return json_response(request, {"output": f"rm: cannot remove '{path}': Permission denied"}, 200)
# Check if path is valid
if not is_valid_file(path) and not is_valid_binary(path):
return json_response(request, {"output": f"rm: cannot remove '{path}': No such file"}, 200)
# Get the node
node = get_node(path)
# Only let user delete files if the permission is >1 (writable)
if node.get("permissions", 0) < 2:
return json_response(request, {"output": f"rm: cannot remove '{path}': Permission denied"}, 200)
# Only let the user remove files
if node.get("type", 1) != 1:
return json_response(request, {"output": f"rm: cannot remove '{path}': Is a directory"}, 200)
# Remove the file from session paths
if remove_node(path):
output = f"Removed '{path}'"
else:
output = f"Failed to remove '{path}'"
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"rm: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/tree", methods=["POST"])
def tree():
try:
data = request.get_json() or {}
args = data.get("args", "")
path = session.get("pwd", f"/home/{getClientIP(request)}")
if args:
path = args
if not path.startswith("/"):
path = sanitize_path(session.get("pwd", f"/home/{getClientIP(request)}") + "/" + path)
if not is_valid_path(path):
return json_response(request, {"output": f"tree: cannot access '{path}': No such file or directory"}, 200)
output = build_tree(path).rstrip()
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"tree: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/<command>", methods=["POST"])
def execute_catch(command):
try:
# Basic command processing
if command == "help":
output = "Available commands:\n" + \
"\n".join(f" {cmd}: {desc}" for cmd, desc in COMMANDS.items())
elif command == "about":
output = "This is a simulated terminal interface created by Nathan Woodburn."
elif command == "whoami":
output = getClientIP(request)
elif command == "pwd":
# Get pwd from session or simulate
output = session.get("pwd", f"/home/{getClientIP(request)}")
else:
output = f"Command not found: {command}"
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"{command}: {str(e)}"}, 200)
@terminal_bp.route("/terminal/execute/reset", methods=["POST"])
def reset():
try:
# Clear the session data related to terminal
session.pop("pwd", None)
session.pop("paths", None)
output = "Terminal session has been reset."
return json_response(request, {"output": output}, 200)
except Exception as e:
return json_response(request, {"output": f"reset: {str(e)}"}, 200)
# Add error handler at the end
@terminal_bp.errorhandler(Exception)
def handle_terminal_error(error):
"""Handle all exceptions in terminal blueprint."""
error_message = f"Terminal error: {str(error)}"
return json_response(request, {"output": error_message}, 500)

View File

@@ -1,15 +1,16 @@
from flask import Blueprint, render_template, make_response, request, jsonify, send_from_directory, redirect from flask import Blueprint, make_response, request, jsonify, send_from_directory, redirect
from tools import error_response
import os import os
wk_bp = Blueprint('well-known', __name__) app = Blueprint('well-known', __name__, url_prefix='/.well-known')
@wk_bp.route("/<path:path>") @app.route("/<path:path>")
def index(path): def index(path):
return send_from_directory(".well-known", path) return send_from_directory(".well-known", path)
@wk_bp.route("/wallets/<path:path>") @app.route("/wallets/<path:path>")
def wallets(path): def wallets(path):
if path[0] == "." and 'proof' not in path: if path[0] == "." and 'proof' not in path:
return send_from_directory( return send_from_directory(
@@ -25,10 +26,10 @@ def wallets(path):
if os.path.isfile(".well-known/wallets/" + path.upper()): if os.path.isfile(".well-known/wallets/" + path.upper()):
return redirect("/.well-known/wallets/" + path.upper(), code=302) return redirect("/.well-known/wallets/" + path.upper(), code=302)
return render_template("404.html"), 404 return error_response(request)
@wk_bp.route("/nostr.json") @app.route("/nostr.json")
def nostr(): def nostr():
# Get name parameter # Get name parameter
name = request.args.get("name") name = request.args.get("name")
@@ -50,7 +51,7 @@ def nostr():
) )
@wk_bp.route("/xrp-ledger.toml") @app.route("/xrp-ledger.toml")
def xrp(): def xrp():
# Create a response with the xrp-ledger.toml file # Create a response with the xrp-ledger.toml file
with open(".well-known/xrp-ledger.toml") as file: with open(".well-known/xrp-ledger.toml") as file:

250
cache_helper.py Normal file
View File

@@ -0,0 +1,250 @@
"""
Cache helper module for expensive API calls and configuration.
Provides centralized caching with TTL for external API calls.
"""
import datetime
import os
import json
import requests
from functools import lru_cache
# Cache storage for NC_CONFIG with timestamp
_nc_config_cache = {"data": None, "timestamp": 0}
_nc_config_ttl = 3600 # 1 hour cache
def get_nc_config():
"""
Get NC_CONFIG with caching (1 hour TTL).
Falls back to default config on error.
Returns:
dict: Configuration dictionary
"""
global _nc_config_cache
current_time = datetime.datetime.now().timestamp()
# Check if cache is valid
if _nc_config_cache["data"] and (current_time - _nc_config_cache["timestamp"]) < _nc_config_ttl:
return _nc_config_cache["data"]
# Fetch new config
try:
config = requests.get(
"https://cloud.woodburn.au/s/4ToXgFe3TnnFcN7/download/website-conf.json",
timeout=5
).json()
_nc_config_cache = {"data": config, "timestamp": current_time}
return config
except Exception as e:
print(f"Error fetching NC_CONFIG: {e}")
# Return cached data if available, otherwise default
if _nc_config_cache["data"]:
return _nc_config_cache["data"]
return {"time-zone": 10, "message": ""}
# Cache storage for git data
_git_data_cache = {"data": None, "timestamp": 0}
_git_data_ttl = 300 # 5 minutes cache
def get_git_latest_activity():
"""
Get latest git activity with caching (5 minute TTL).
Returns:
dict: Git activity data or default values
"""
global _git_data_cache
current_time = datetime.datetime.now().timestamp()
# Check if cache is valid
if _git_data_cache["data"] and (current_time - _git_data_cache["timestamp"]) < _git_data_ttl:
return _git_data_cache["data"]
# Fetch new data
try:
git = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1",
headers={"Authorization": os.getenv("GIT_AUTH") or os.getenv("git_token") or ""},
timeout=5
)
git_data = git.json()
if git_data and len(git_data) > 0:
result = git_data[0]
_git_data_cache = {"data": result, "timestamp": current_time}
return result
except Exception as e:
print(f"Error fetching git data: {e}")
# Return cached or default
if _git_data_cache["data"]:
return _git_data_cache["data"]
return {
"repo": {
"html_url": "https://nathan.woodburn.au",
"name": "nathanwoodburn.github.io",
"description": "Personal website",
}
}
# Cache storage for projects
_projects_cache = {"data": None, "timestamp": 0}
_projects_ttl = 7200 # 2 hours cache
def get_projects(limit=3):
"""
Get projects list with caching (2 hour TTL).
Args:
limit (int): Number of projects to return
Returns:
list: List of project dictionaries
"""
global _projects_cache
current_time = datetime.datetime.now().timestamp()
# Check if cache is valid
if _projects_cache["data"] and (current_time - _projects_cache["timestamp"]) < _projects_ttl:
return _projects_cache["data"][:limit]
# Fetch new data
try:
projects = []
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos",
timeout=5
)
projects = projectsreq.json()
# Check for pagination
pageNum = 2
while 'rel="next"' in projectsreq.headers.get("link", ""):
projectsreq = requests.get(
f"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos?page={pageNum}",
timeout=5
)
projects += projectsreq.json()
pageNum += 1
# Safety limit
if pageNum > 10:
break
# Process projects
for project in projects:
if project.get("avatar_url") in ("https://git.woodburn.au/", ""):
project["avatar_url"] = "/favicon.png"
project["name"] = project["name"].replace("_", " ").replace("-", " ")
# Sort by last updated
projects_sorted = sorted(projects, key=lambda x: x.get("updated_at", ""), reverse=True)
# Remove duplicates by name
seen_names = set()
unique_projects = []
for project in projects_sorted:
if project["name"] not in seen_names:
unique_projects.append(project)
seen_names.add(project["name"])
_projects_cache = {"data": unique_projects, "timestamp": current_time}
return unique_projects[:limit]
except Exception as e:
print(f"Error fetching projects: {e}")
if _projects_cache["data"]:
return _projects_cache["data"][:limit]
return []
# Cache storage for uptime status
_uptime_cache = {"data": None, "timestamp": 0}
_uptime_ttl = 300 # 5 minutes cache
def get_uptime_status():
"""
Get uptime status with caching (5 minute TTL).
Returns:
bool: True if services are up, False otherwise
"""
global _uptime_cache
current_time = datetime.datetime.now().timestamp()
# Check if cache is valid
if _uptime_cache["data"] is not None and (current_time - _uptime_cache["timestamp"]) < _uptime_ttl:
return _uptime_cache["data"]
# Fetch new data
try:
uptime = requests.get(
"https://uptime.woodburn.au/api/status-page/main/badge",
timeout=5
)
content = uptime.content.decode("utf-8").lower()
status = "maintenance" in content or uptime.content.count(b"Up") > 1
_uptime_cache = {"data": status, "timestamp": current_time}
return status
except Exception as e:
print(f"Error fetching uptime: {e}")
# Return cached or default (assume up)
if _uptime_cache["data"] is not None:
return _uptime_cache["data"]
return True
# Cached wallet data loaders
@lru_cache(maxsize=1)
def get_wallet_tokens():
"""
Get wallet tokens with caching.
Returns:
list: List of token dictionaries
"""
try:
with open(".well-known/wallets/.tokens") as file:
return json.load(file)
except Exception as e:
print(f"Error loading tokens: {e}")
return []
@lru_cache(maxsize=1)
def get_coin_names():
"""
Get coin names with caching.
Returns:
dict: Dictionary of coin names
"""
try:
with open(".well-known/wallets/.coins") as file:
return json.load(file)
except Exception as e:
print(f"Error loading coin names: {e}")
return {}
@lru_cache(maxsize=1)
def get_wallet_domains():
"""
Get wallet domains with caching.
Returns:
dict: Dictionary of wallet domains
"""
try:
if os.path.isfile(".well-known/wallets/.domains"):
with open(".well-known/wallets/.domains") as file:
return json.load(file)
except Exception as e:
print(f"Error loading domains: {e}")
return {}

100
curl.py
View File

@@ -1,11 +1,13 @@
from flask import render_template from flask import render_template
from tools import error_response, getAddress, get_tools_data, getClientIP from tools import getAddress, get_tools_data, getClientIP
import os import os
from functools import lru_cache from functools import lru_cache
import requests
from blueprints.spotify import get_spotify_track from blueprints.spotify import get_spotify_track
from cache_helper import get_git_latest_activity, get_projects as get_projects_cached
MAX_WIDTH = 80
def clean_path(path:str): def clean_path(path:str):
path = path.strip("/ ").lower() path = path.strip("/ ").lower()
# Strip any .html extension # Strip any .html extension
@@ -22,90 +24,26 @@ def get_header():
with open("templates/header.ascii", "r") as f: with open("templates/header.ascii", "r") as f:
return f.read() return f.read()
@lru_cache(maxsize=1) @lru_cache(maxsize=16)
def get_current_project(): def get_current_project():
git = requests.get( git = get_git_latest_activity()
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1", repo_name = git["repo"]["name"].lower()
headers={"Authorization": os.getenv("GIT_AUTH") if os.getenv("GIT_AUTH") else os.getenv("git_token")},
)
git = git.json()
git = git[0]
repo_name = git["repo"]["name"]
repo_name = repo_name.lower()
repo_description = git["repo"]["description"] repo_description = git["repo"]["description"]
if not repo_description: if not repo_description:
return f"{repo_name}" return f"[1;36m{repo_name}[0m"
return f"{repo_name} - {repo_description}" return f"[1;36m{repo_name}[0m - [1m{repo_description}[0m"
@lru_cache(maxsize=1) @lru_cache(maxsize=16)
def get_projects(): def get_projects():
projectsreq = requests.get( projects_data = get_projects_cached(limit=5)
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos"
)
projects = projectsreq.json()
# Check for next page
pageNum = 1
while 'rel="next"' in projectsreq.headers["link"]:
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos?page="
+ str(pageNum)
)
projects += projectsreq.json()
pageNum += 1
# Sort by last updated
projectsList = sorted(
projects, key=lambda x: x["updated_at"], reverse=True)
projects = "" projects = ""
projectNum = 0 for project in projects_data:
includedNames = [] projects += f"""[1m{project['name']}[0m - {project['description'] if project['description'] else 'No description'}
while len(includedNames) < 5 and projectNum < len(projectsList):
# Avoid duplicates
if projectsList[projectNum]["name"] in includedNames:
projectNum += 1
continue
includedNames.append(projectsList[projectNum]["name"])
project = projectsList[projectNum]
projects += f"""{project['name']} - {project['description'] if project['description'] else 'No description'}
{project['html_url']} {project['html_url']}
""" """
projectNum += 1
return projects return projects
@lru_cache(maxsize=32)
def valid_curl_path(path: str) -> bool:
"""Check if the given path corresponds to a valid curl/ascii response."""
path = clean_path(path)
# Special cases
special_paths = [
"index",
"projects",
"donate",
"donate/more",
"tools"
]
if path in special_paths:
return True
# Check for donate/<coin> pattern
if path.startswith("donate/"):
coin = path.split("/")[1]
address = getAddress(coin)
if address != "":
return True
# Check if .ascii template exists
if os.path.exists(f"templates/{path}.ascii"):
return True
return False
def curl_response(request): def curl_response(request):
# Check if <path>.ascii exists # Check if <path>.ascii exists
path = clean_path(request.path) path = clean_path(request.path)
@@ -144,8 +82,16 @@ def curl_response(request):
tools = get_tools_data() tools = get_tools_data()
return render_template("tools.ascii",header=get_header(),tools=tools), 200, {'Content-Type': 'text/plain; charset=utf-8'} return render_template("tools.ascii",header=get_header(),tools=tools), 200, {'Content-Type': 'text/plain; charset=utf-8'}
if os.path.exists(f"templates/{path}.ascii"): if os.path.exists(f"templates/{path}.ascii"):
return render_template(f"{path}.ascii",header=get_header()), 200, {'Content-Type': 'text/plain; charset=utf-8'} return render_template(f"{path}.ascii",header=get_header()), 200, {'Content-Type': 'text/plain; charset=utf-8'}
return error_response(request) # Fallback to html if it exists
if os.path.exists(f"templates/{path}.html"):
return render_template(f"{path}.html")
# Return curl error page
error = {
"code": 404,
"message": "The requested resource was not found on this server."
}
return render_template("error.ascii",header=get_header(),error=error), 404, {'Content-Type': 'text/plain; charset=utf-8'}

Binary file not shown.

BIN
data/resume_support.pdf Normal file

Binary file not shown.

View File

@@ -10,7 +10,8 @@
"url": "https://domains.hns.au", "url": "https://domains.hns.au",
"img": "/assets/img/external/HNSAU.webp", "img": "/assets/img/external/HNSAU.webp",
"name": "HNSAU Registry", "name": "HNSAU Registry",
"description": "An easy to use DNS provider and domain reselling platform" "description": "An easy to use DNS provider and domain reselling platform",
"enabled": false
}, },
{ {
"url": "https://hns.au", "url": "https://hns.au",
@@ -22,7 +23,8 @@
"url": "https://hnshosting.au", "url": "https://hnshosting.au",
"img": "/favicon.png", "img": "/favicon.png",
"name": "HNS Hosting", "name": "HNS Hosting",
"description": "Simple Wordpress hosting for Handshake domains with builtin SSL using DANE" "description": "Simple Wordpress hosting for Handshake domains with builtin SSL using DANE",
"enabled": false
}, },
{ {
"url": "https://firewallet.au", "url": "https://firewallet.au",
@@ -34,7 +36,8 @@
"url": "https://shakecities.com", "url": "https://shakecities.com",
"img": "/assets/img/external/HNSW.png", "img": "/assets/img/external/HNSW.png",
"name": "ShakeCities", "name": "ShakeCities",
"description": "A single page website creator where each user's page on their free HNS domain" "description": "A single page website creator where each user's page on their free HNS domain",
"enabled": false
}, },
{ {
"url": "https://git.woodburn.au", "url": "https://git.woodburn.au",
@@ -66,5 +69,17 @@
"img": "https://ipfs.hnsproxy.au/fireportal.png", "img": "https://ipfs.hnsproxy.au/fireportal.png",
"name": "FirePortal", "name": "FirePortal",
"description": "A Handshake domain IPFS gateway that allows you to access IPFS content using Handshake domains" "description": "A Handshake domain IPFS gateway that allows you to access IPFS content using Handshake domains"
},
{
"url": "https://hsd.hns.au/",
"img": "/favicon.png",
"name": "Fire HSD",
"description": "A free public API for Handshake (HSD)"
},
{
"url": "https://time.c.woodburn.au/",
"img": "/favicon.png",
"name": "Timezone Converter",
"description": "A simple site and API for converting timezones"
} }
] ]

View File

@@ -23,59 +23,60 @@
"url": "https://code.visualstudio.com/", "url": "https://code.visualstudio.com/",
"description": "Source-code editor developed by Microsoft" "description": "Source-code editor developed by Microsoft"
}, },
{
"name": "Vesktop",
"type": "Desktop Applications",
"url": "https://vesktop.dev/",
"description": "Vesktop is a customizable and privacy friendly Discord desktop app!"
},
{ {
"name": "Zellij", "name": "Zellij",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://zellij.dev/", "url": "https://zellij.dev/",
"description": "A terminal workspace and multiplexer" "description": "A terminal workspace and multiplexer",
"demo": "https://asciinema.c.woodburn.au/a/10"
}, },
{ {
"name": "Fx", "name": "Fx",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://fx.wtf/", "url": "https://fx.wtf/",
"description": "A command-line JSON viewer and processor", "description": "A command-line JSON viewer and processor",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/4.js\" id=\"asciicast-4\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/4"
"demo_url": "https://asciinema.c.woodburn.au/a/4"
}, },
{ {
"name": "Zoxide", "name": "Zoxide",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://github.com/ajeetdsouza/zoxide", "url": "https://github.com/ajeetdsouza/zoxide",
"description": "cd but with fuzzy matching and other cool features", "description": "cd but with fuzzy matching and other cool features",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/5.js\" id=\"asciicast-5\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/5"
"demo_url": "https://asciinema.c.woodburn.au/a/5"
}, },
{ {
"name": "Atuin", "name": "Atuin",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://atuin.sh/", "url": "https://atuin.sh/",
"description": "A next-generation shell history manager", "description": "A next-generation shell history manager",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/6.js\" id=\"asciicast-6\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/6"
"demo_url": "https://asciinema.c.woodburn.au/a/6"
}, },
{ {
"name": "Tmate", "name": "Tmate",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://tmate.io/", "url": "https://tmate.io/",
"description": "Instant terminal sharing", "description": "Instant terminal sharing",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/7.js\" id=\"asciicast-7\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/7"
"demo_url": "https://asciinema.c.woodburn.au/a/7"
}, },
{ {
"name": "Eza", "name": "Eza",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://eza.rocks/", "url": "https://eza.rocks/",
"description": "A modern replacement for 'ls'", "description": "A modern replacement for 'ls'",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/8.js\" id=\"asciicast-8\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/8"
"demo_url": "https://asciinema.c.woodburn.au/a/8"
}, },
{ {
"name": "Bat", "name": "Bat",
"type": "Terminal Tools", "type": "Terminal Tools",
"url": "https://github.com/sharkdp/bat", "url": "https://github.com/sharkdp/bat",
"description": "A cat clone with syntax highlighting and Git integration", "description": "A cat clone with syntax highlighting and Git integration",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/9.js\" id=\"asciicast-9\" async=\"true\"></script>", "demo": "https://asciinema.c.woodburn.au/a/9"
"demo_url": "https://asciinema.c.woodburn.au/a/9"
}, },
{ {
"name": "Oh My Zsh", "name": "Oh My Zsh",

31
pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[project]
name = "nathanwoodburn-github-io"
version = "1.1.0"
description = "Nathan.Woodburn Personal Website"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"ansi2html>=1.9.2",
"beautifulsoup4>=4.14.2",
"cachetools>=6.2.1",
"cloudflare>=4.3.1",
"flask>=3.1.2",
"flask-cors>=6.0.1",
"gunicorn>=23.0.0",
"markdown>=3.9",
"pillow>=12.0.0",
"pydantic>=2.12.3",
"pygments>=2.19.2",
"python-dateutil>=2.9.0.post0",
"python-dotenv>=1.2.1",
"qrcode>=8.2",
"requests>=2.32.5",
"solana>=0.36.9",
"solders>=0.26.0",
]
[dependency-groups]
dev = [
"pre-commit>=4.4.0",
"ruff>=0.14.5",
]

View File

@@ -1,18 +1,475 @@
pydantic # This file was autogenerated by uv via the following command:
flask # uv export --frozen --output-file=requirements.txt
Flask-Cors annotated-types==0.7.0 \
python-dotenv --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
gunicorn --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
requests # via pydantic
cloudflare ansi2html==1.9.2 \
qrcode --hash=sha256:3453bf87535d37b827b05245faaa756dbab4ec3d69925e352b6319c3c955c0a5 \
Pillow --hash=sha256:dccb75aa95fb018e5d299be2b45f802952377abfdce0504c17a6ee6ef0a420c5
ansi2html # via nathanwoodburn-github-io
cachetools anyio==4.11.0 \
solana --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \
solders --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4
weasyprint # via
markdown # cloudflare
pygments # httpx
beautifulsoup4 beautifulsoup4==4.14.2 \
python-dateutil --hash=sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e \
--hash=sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515
# via nathanwoodburn-github-io
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
cachetools==6.2.1 \
--hash=sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701 \
--hash=sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201
# via nathanwoodburn-github-io
certifi==2025.10.5 \
--hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \
--hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43
# via
# httpcore
# httpx
# requests
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit
charset-normalizer==3.4.4 \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9
# via requests
click==8.3.0 \
--hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \
--hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4
# via flask
cloudflare==4.3.1 \
--hash=sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6 \
--hash=sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258
# via nathanwoodburn-github-io
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# click
# qrcode
construct==2.10.68 \
--hash=sha256:7b2a3fd8e5f597a5aa1d614c3bd516fa065db01704c72a1efaaeec6ef23d8b45
# via construct-typing
construct-typing==0.6.2 \
--hash=sha256:948e998cfc003681dc34f2d071c3a688cf35b805cbe107febbc488ef967ccba1 \
--hash=sha256:ebea6989ac622d0c4eb457092cef0c7bfbcfa110bd018670fea7064d0bc09e47
# via solana
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
distro==1.9.0 \
--hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
--hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
# via cloudflare
filelock==3.20.0 \
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
# via virtualenv
flask==3.1.2 \
--hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \
--hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c
# via
# flask-cors
# nathanwoodburn-github-io
flask-cors==6.0.1 \
--hash=sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c \
--hash=sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db
# via nathanwoodburn-github-io
gunicorn==23.0.0 \
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
# via nathanwoodburn-github-io
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via httpcore
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via
# cloudflare
# solana
identify==2.6.15 \
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
# via pre-commit
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via
# anyio
# httpx
# requests
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
jsonalias==0.1.1 \
--hash=sha256:64f04d935397d579fc94509e1fcb6212f2d081235d9d6395bd10baedf760a769 \
--hash=sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18
# via solders
markdown==3.9 \
--hash=sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280 \
--hash=sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a
# via nathanwoodburn-github-io
markupsafe==3.0.3 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via gunicorn
pillow==12.0.0 \
--hash=sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643 \
--hash=sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e \
--hash=sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6 \
--hash=sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b \
--hash=sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399 \
--hash=sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad \
--hash=sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47 \
--hash=sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b \
--hash=sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52 \
--hash=sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d \
--hash=sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a \
--hash=sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9 \
--hash=sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098 \
--hash=sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905 \
--hash=sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b \
--hash=sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01 \
--hash=sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca \
--hash=sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e \
--hash=sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27 \
--hash=sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e \
--hash=sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8 \
--hash=sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a \
--hash=sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3 \
--hash=sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353 \
--hash=sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee \
--hash=sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b \
--hash=sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a \
--hash=sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7 \
--hash=sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef \
--hash=sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a \
--hash=sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07 \
--hash=sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4 \
--hash=sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4 \
--hash=sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe \
--hash=sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6 \
--hash=sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3 \
--hash=sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9 \
--hash=sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5 \
--hash=sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b \
--hash=sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e \
--hash=sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab \
--hash=sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79 \
--hash=sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2 \
--hash=sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0 \
--hash=sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925 \
--hash=sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b \
--hash=sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced \
--hash=sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c \
--hash=sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 \
--hash=sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9 \
--hash=sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1
# via nathanwoodburn-github-io
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
# via virtualenv
pre-commit==4.4.0 \
--hash=sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813 \
--hash=sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15
pydantic==2.12.3 \
--hash=sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74 \
--hash=sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf
# via
# cloudflare
# nathanwoodburn-github-io
pydantic-core==2.41.4 \
--hash=sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89 \
--hash=sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d \
--hash=sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2 \
--hash=sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af \
--hash=sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d \
--hash=sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e \
--hash=sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894 \
--hash=sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa \
--hash=sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e \
--hash=sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1 \
--hash=sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da \
--hash=sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025 \
--hash=sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5 \
--hash=sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d \
--hash=sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac \
--hash=sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746 \
--hash=sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a \
--hash=sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84 \
--hash=sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12 \
--hash=sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2 \
--hash=sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e \
--hash=sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a \
--hash=sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad \
--hash=sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4 \
--hash=sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf \
--hash=sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0 \
--hash=sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2 \
--hash=sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d \
--hash=sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2 \
--hash=sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d \
--hash=sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02 \
--hash=sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616 \
--hash=sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced \
--hash=sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1 \
--hash=sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c \
--hash=sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4 \
--hash=sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab \
--hash=sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564 \
--hash=sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554
# via pydantic
pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
# via nathanwoodburn-github-io
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via nathanwoodburn-github-io
python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via nathanwoodburn-github-io
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6
# via pre-commit
qrcode==8.2 \
--hash=sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f \
--hash=sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c
# via nathanwoodburn-github-io
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
# via nathanwoodburn-github-io
ruff==0.14.5 \
--hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \
--hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \
--hash=sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4 \
--hash=sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4 \
--hash=sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a \
--hash=sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19 \
--hash=sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7 \
--hash=sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1 \
--hash=sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621 \
--hash=sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb \
--hash=sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1 \
--hash=sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367 \
--hash=sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b \
--hash=sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465 \
--hash=sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f \
--hash=sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594 \
--hash=sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2 \
--hash=sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151 \
--hash=sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via
# anyio
# cloudflare
solana==0.36.9 \
--hash=sha256:e05824f91f95abe5a687914976e8bc78986386156f2106108c696db998c3c542 \
--hash=sha256:f702f6177337c67a982909ef54ef3abce5e795b8cd93edb045bedfa4d13c20c5
# via nathanwoodburn-github-io
solders==0.26.0 \
--hash=sha256:057533892d6fa432c1ce1e2f5e3428802964666c10b57d3d1bcaab86295f046c \
--hash=sha256:1b964efbd7c0b38aef3bf4293ea5938517ae649b9a23e7cd147d889931775aab \
--hash=sha256:36e6a769c5298b887b7588edb171d93709a89302aef75913fe893d11c653739d \
--hash=sha256:3e3973074c17265921c70246a17bcf80972c5b96a3e1ed7f5049101f11865092 \
--hash=sha256:5466616610170aab08c627ae01724e425bcf90085bc574da682e9f3bd954900b \
--hash=sha256:5946ec3f2a340afa9ce5c2b8ab628ae1dea2ad2235551b1297cafdd7e3e5c51a \
--hash=sha256:59b52419452602f697e659199a25acacda8365971c376ef3c0687aecdd929e07 \
--hash=sha256:9c1a0ef5daa1a05934af5fb6e7e32eab7c42cede406c80067fee006f461ffc4a \
--hash=sha256:b3cc55b971ec6ed1b4466fa7e7e09eee9baba492b8cd9e3204e3e1a0c5a0c4aa
# via
# nathanwoodburn-github-io
# solana
soupsieve==2.8 \
--hash=sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c \
--hash=sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f
# via beautifulsoup4
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# beautifulsoup4
# cloudflare
# construct-typing
# pydantic
# pydantic-core
# solana
# solders
# typing-inspection
typing-inspection==0.4.2 \
--hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
--hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
# via pydantic
urllib3==2.5.0 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
# via requests
virtualenv==20.35.4 \
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
# via pre-commit
websockets==15.0.1 \
--hash=sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8 \
--hash=sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375 \
--hash=sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f \
--hash=sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4 \
--hash=sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22 \
--hash=sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675 \
--hash=sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151 \
--hash=sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d \
--hash=sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee \
--hash=sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa \
--hash=sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561 \
--hash=sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931 \
--hash=sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f
# via solana
werkzeug==3.1.3 \
--hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \
--hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746
# via
# flask
# flask-cors

308
server.py
View File

@@ -18,31 +18,38 @@ import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_H from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_H
from ansi2html import Ansi2HTMLConverter from ansi2html import Ansi2HTMLConverter
from PIL import Image from PIL import Image
# Import blueprints # Import blueprints
from blueprints.now import now_bp from blueprints import now, blog, wellknown, api, podcast, acme, spotify
from blueprints.blog import blog_bp from tools import (
from blueprints.wellknown import wk_bp isCLI,
from blueprints.api import api_bp isCrawler,
from blueprints.podcast import podcast_bp getAddress,
from blueprints.acme import acme_bp getFilePath,
from blueprints.spotify import spotify_bp error_response,
from blueprints.terminal import terminal_bp getClientIP,
from tools import isCurl, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getHandshakeScript, get_tools_data json_response,
from curl import curl_response, valid_curl_path getHandshakeScript,
get_tools_data,
)
from curl import curl_response
from cache_helper import (
get_nc_config,
get_git_latest_activity,
get_projects,
get_uptime_status,
get_wallet_tokens,
get_coin_names,
get_wallet_domains,
)
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey")
CORS(app) CORS(app)
# Register blueprints # Register blueprints
app.register_blueprint(now_bp, url_prefix='/now') for module in [now, blog, wellknown, api, podcast, acme, spotify]:
app.register_blueprint(blog_bp, url_prefix='/blog') app.register_blueprint(module.app)
app.register_blueprint(wk_bp, url_prefix='/.well-known')
app.register_blueprint(api_bp, url_prefix='/api/v1')
app.register_blueprint(podcast_bp)
app.register_blueprint(acme_bp)
app.register_blueprint(spotify_bp, url_prefix='/spotify')
app.register_blueprint(terminal_bp)
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -57,27 +64,20 @@ RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
RESTRICTED_ROUTES = ["ascii"] RESTRICTED_ROUTES = ["ascii"]
REDIRECT_ROUTES = { REDIRECT_ROUTES = {
"contact": "/#contact" "contact": "/#contact",
} "old": "/now/old",
DOWNLOAD_ROUTES = { "/meet": "https://cloud.woodburn.au/apps/calendar/appointment/PamrmmspWJZr",
"pgp": "data/nathanwoodburn.asc" "/meeting": "https://cloud.woodburn.au/apps/calendar/appointment/PamrmmspWJZr",
"/appointment": "https://cloud.woodburn.au/apps/calendar/appointment/PamrmmspWJZr",
} }
DOWNLOAD_ROUTES = {"pgp": "data/nathanwoodburn.asc"}
SITES = [] SITES = []
if os.path.isfile("data/sites.json"): if os.path.isfile("data/sites.json"):
with open("data/sites.json") as file: with open("data/sites.json") as file:
SITES = json.load(file) SITES = json.load(file)
# Remove any sites that are not enabled # Remove any sites that are not enabled
SITES = [ SITES = [site for site in SITES if "enabled" not in site or site["enabled"]]
site for site in SITES if "enabled" not in site or site["enabled"]
]
PROJECTS = []
PROJECTS_UPDATED = 0
NC_CONFIG = requests.get(
"https://cloud.woodburn.au/s/4ToXgFe3TnnFcN7/download/website-conf.json"
).json()
# endregion # endregion
@@ -123,6 +123,13 @@ def asset(path):
return error_response(request) return error_response(request)
@app.route("/fonts/<path:path>")
def fonts(path):
if os.path.isfile("templates/assets/fonts/" + path):
return send_from_directory("templates/assets/fonts", path)
return error_response(request)
@app.route("/sitemap") @app.route("/sitemap")
@app.route("/sitemap.xml") @app.route("/sitemap.xml")
def sitemap(): def sitemap():
@@ -162,6 +169,7 @@ def download(path):
return error_response(request, message="File not found") return error_response(request, message="File not found")
# endregion # endregion
# region PWA routes # region PWA routes
@@ -186,26 +194,23 @@ def manifest():
def serviceWorker(): def serviceWorker():
return send_from_directory("pwa", "sw.js") return send_from_directory("pwa", "sw.js")
# endregion # endregion
# region Misc routes # region Misc routes
@app.route("/meet")
@app.route("/meeting")
@app.route("/appointment")
def meetingLink():
return redirect(
"https://cloud.woodburn.au/apps/calendar/appointment/PamrmmspWJZr", code=302
)
@app.route("/links") @app.route("/links")
def links(): def links():
return render_template("link.html") return render_template("link.html")
@app.route("/actions.json")
def sol_actions():
return jsonify(
{"rules": [{"pathPattern": "/donate**", "apiPath": "/api/v1/donate**"}]}
)
@app.route("/api/<path:function>") @app.route("/api/<path:function>")
def api_legacy(function): def api_legacy(function):
# Check if function is in api blueprint # Check if function is in api blueprint
@@ -216,12 +221,6 @@ def api_legacy(function):
return error_response(request, message="404 Not Found", code=404) return error_response(request, message="404 Not Found", code=404)
@app.route("/actions.json")
def sol_actions():
return jsonify(
{"rules": [{"pathPattern": "/donate**", "apiPath": "/api/v1/donate**"}]}
)
# endregion # endregion
# region Main routes # region Main routes
@@ -229,9 +228,6 @@ def sol_actions():
@app.route("/") @app.route("/")
def index(): def index():
global PROJECTS
global PROJECTS_UPDATED
# Check if host if podcast.woodburn.au # Check if host if podcast.woodburn.au
if "podcast.woodburn.au" in request.host: if "podcast.woodburn.au" in request.host:
return render_template("podcast.html") return render_template("podcast.html")
@@ -247,7 +243,7 @@ def index():
# Always load if load is in the query string # Always load if load is in the query string
if request.args.get("load"): if request.args.get("load"):
loaded = False loaded = False
if isCurl(request): if isCLI(request):
return curl_response(request) return curl_response(request)
if not loaded and not isCrawler(request): if not loaded and not isCrawler(request):
@@ -262,79 +258,22 @@ def index():
resp.set_cookie("loaded", "true", max_age=604800) resp.set_cookie("loaded", "true", max_age=604800)
return resp return resp
try: # Use cached git data
git = requests.get( git = get_git_latest_activity()
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1", repo_name = git["repo"]["name"].lower()
headers={"Authorization": os.getenv("GIT_AUTH") if os.getenv("GIT_AUTH") else os.getenv("git_token")},
)
git = git.json()
git = git[0]
repo_name = git["repo"]["name"]
repo_name = repo_name.lower()
repo_description = git["repo"]["description"] repo_description = git["repo"]["description"]
except Exception as e:
repo_name = "nathanwoodburn.github.io"
repo_description = "Personal website"
git = {
"repo": {
"html_url": "https://nathan.woodburn.au",
"name": "nathanwoodburn.github.io",
"description": "Personal website",
}
}
print(f"Error getting git data: {e}")
# Get only repo names for the newest updates # Use cached projects data
if PROJECTS == [] or PROJECTS_UPDATED < (datetime.datetime.now() - datetime.timedelta( projects = get_projects(limit=3)
hours=2
)).timestamp():
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos"
)
PROJECTS = projectsreq.json()
# Check for next page
pageNum = 1
while 'rel="next"' in projectsreq.headers["link"]:
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos?page="
+ str(pageNum)
)
PROJECTS += projectsreq.json()
pageNum += 1
for project in PROJECTS:
if (
project["avatar_url"] == "https://git.woodburn.au/"
or project["avatar_url"] == ""
):
project["avatar_url"] = "/favicon.png"
project["name"] = project["name"].replace(
"_", " ").replace("-", " ")
# Sort by last updated
projectsList = sorted(
PROJECTS, key=lambda x: x["updated_at"], reverse=True)
PROJECTS = []
projectNames = []
projectNum = 0
while len(PROJECTS) < 3:
if projectsList[projectNum]["name"] not in projectNames:
PROJECTS.append(projectsList[projectNum])
projectNames.append(projectsList[projectNum]["name"])
projectNum += 1
PROJECTS_UPDATED = datetime.datetime.now().timestamp()
# Use cached uptime status
uptime = get_uptime_status()
custom = "" custom = ""
# Check for downtime
uptime = requests.get(
"https://uptime.woodburn.au/api/status-page/main/badge")
uptime = uptime.content.count(b"Up") > 1
if uptime: if uptime:
custom += "<style>#downtime{display:none !important;}</style>" custom += "<style>#downtime{display:none !important;}</style>"
else: else:
custom += "<style>#downtime{opacity:1;}</style>" custom += "<style>#downtime{opacity:1;}</style>"
# Special names # Special names
if repo_name == "nathanwoodburn.github.io": if repo_name == "nathanwoodburn.github.io":
repo_name = "Nathan.Woodburn/" repo_name = "Nathan.Woodburn/"
@@ -342,8 +281,9 @@ def index():
html_url = git["repo"]["html_url"] html_url = git["repo"]["html_url"]
repo = '<a href="' + html_url + '" target="_blank">' + repo_name + "</a>" repo = '<a href="' + html_url + '" target="_blank">' + repo_name + "</a>"
# Get time # Get time using cached config
timezone_offset = datetime.timedelta(hours=NC_CONFIG["time-zone"]) nc_config = get_nc_config()
timezone_offset = datetime.timedelta(hours=nc_config["time-zone"])
timezone = datetime.timezone(offset=timezone_offset) timezone = datetime.timezone(offset=timezone_offset)
time = datetime.datetime.now(tz=timezone) time = datetime.datetime.now(tz=timezone)
@@ -366,7 +306,7 @@ def index():
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
} }
""" """
time += f"startClock({NC_CONFIG['time-zone']});" time += f"startClock({nc_config['time-zone']});"
time += "</script>" time += "</script>"
HNSaddress = getAddress("HNS") HNSaddress = getAddress("HNS")
@@ -386,9 +326,9 @@ def index():
repo_description=repo_description, repo_description=repo_description,
custom=custom, custom=custom,
sites=SITES, sites=SITES,
projects=PROJECTS, projects=projects,
time=time, time=time,
message=NC_CONFIG.get("message",""), message=nc_config.get("message", ""),
), ),
200, 200,
{"Content-Type": "text/html"}, {"Content-Type": "text/html"},
@@ -397,41 +337,34 @@ def index():
return resp return resp
# region Donate # region Donate
@app.route("/donate") @app.route("/donate")
def donate(): def donate():
if isCurl(request): if isCLI(request):
return curl_response(request) return curl_response(request)
coinList = os.listdir(".well-known/wallets") coinList = os.listdir(".well-known/wallets")
coinList = [file for file in coinList if file[0] != "."] coinList = [file for file in coinList if file[0] != "."]
coinList.sort() coinList.sort()
tokenList = [] tokenList = get_wallet_tokens()
coinNames = get_coin_names()
with open(".well-known/wallets/.tokens") as file:
tokenList = file.read()
tokenList = json.loads(tokenList)
coinNames = {}
with open(".well-known/wallets/.coins") as file:
coinNames = file.read()
coinNames = json.loads(coinNames)
coins = "" coins = ""
default_coins = ["btc", "eth", "hns", "sol", "xrp", "ada", "dot"] default_coins = ["btc", "eth", "hns", "sol", "xrp", "ada", "dot"]
for file in coinList: for file in coinList:
if file in coinNames: coin_name = coinNames.get(file, file)
coins += f'<a class="dropdown-item" style="{"display:none;" if file.lower() not in default_coins else ""}" href="?c={file.lower()}">{coinNames[file]}</a>' display_style = "" if file.lower() in default_coins else "display:none;"
else: coins += f'<a class="dropdown-item" style="{display_style}" href="?c={file.lower()}">{coin_name}</a>'
coins += f'<a class="dropdown-item" style="{"display:none;" if file.lower() not in default_coins else ""}" href="?c={file.lower()}">{file}</a>'
for token in tokenList: for token in tokenList:
if token["chain"] != "null": chain_display = f" on {token['chain']}" if token["chain"] != "null" else ""
coins += f'<a class="dropdown-item" style="display:none;" href="?t={token["symbol"].lower()}&c={token["chain"].lower()}">{token["name"]} ({token["symbol"] + " on " if token["symbol"] != token["name"] else ""}{token["chain"]})</a>' symbol_display = f" ({token['symbol']}{chain_display})" if token["symbol"] != token["name"] else chain_display
else: coins += f'<a class="dropdown-item" style="display:none;" href="?t={token["symbol"].lower()}&c={token["chain"].lower()}">{token["name"]}{symbol_display}</a>'
coins += f'<a class="dropdown-item" style="display:none;" href="?t={token["symbol"].lower()}&c={token["chain"].lower()}">{token["name"]} ({token["symbol"] if token["symbol"] != token["name"] else ""})</a>'
crypto = request.args.get("c") crypto = request.args.get("c")
if not crypto: if not crypto:
@@ -458,7 +391,6 @@ def donate():
token = {"name": "Unknown token", "symbol": token, "chain": crypto} token = {"name": "Unknown token", "symbol": token, "chain": crypto}
address = "" address = ""
domain = ""
cryptoHTML = "" cryptoHTML = ""
proof = "" proof = ""
@@ -468,10 +400,12 @@ def donate():
if os.path.isfile(f".well-known/wallets/{crypto}"): if os.path.isfile(f".well-known/wallets/{crypto}"):
with open(f".well-known/wallets/{crypto}") as file: with open(f".well-known/wallets/{crypto}") as file:
address = file.read() address = file.read()
coin_display = coinNames.get(crypto, crypto)
if not token: if not token:
cryptoHTML += f"<br>Donate with {coinNames[crypto] if crypto in coinNames else crypto}:" cryptoHTML += f"<br>Donate with {coin_display}:"
else: else:
cryptoHTML += f'<br>Donate with {token["name"]} {"("+token["symbol"]+") " if token["symbol"] != token["name"] else ""}on {crypto}:' token_symbol = f" ({token['symbol']})" if token['symbol'] != token['name'] else ""
cryptoHTML += f"<br>Donate with {token['name']}{token_symbol} on {crypto}:"
cryptoHTML += f'<br><code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-address" class="address" style="color: rgb(242,90,5);display: inline-block;" data-bs-original-title="Click to copy">{address}</code>' cryptoHTML += f'<br><code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-address" class="address" style="color: rgb(242,90,5);display: inline-block;" data-bs-original-title="Click to copy">{address}</code>'
if proof: if proof:
@@ -479,25 +413,23 @@ def donate():
elif token: elif token:
if "address" in token: if "address" in token:
address = token["address"] address = token["address"]
cryptoHTML += f'<br>Donate with {token["name"]} {"("+token["symbol"]+")" if token["symbol"] != token["name"] else ""}{" on "+crypto if crypto != "NULL" else ""}:' token_symbol = f" ({token['symbol']})" if token['symbol'] != token['name'] else ""
chain_display = f" on {crypto}" if crypto != 'NULL' else ""
cryptoHTML += f"<br>Donate with {token['name']}{token_symbol}{chain_display}:"
cryptoHTML += f'<br><code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-address" class="address" style="color: rgb(242,90,5);display: inline-block;" data-bs-original-title="Click to copy">{address}</code>' cryptoHTML += f'<br><code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-address" class="address" style="color: rgb(242,90,5);display: inline-block;" data-bs-original-title="Click to copy">{address}</code>'
if proof: if proof:
cryptoHTML += proof cryptoHTML += proof
else: else:
cryptoHTML += f'<br>Invalid offchain token: {token["symbol"]}<br>' cryptoHTML += f"<br>Invalid offchain token: {token['symbol']}<br>"
else: else:
cryptoHTML += f"<br>Invalid chain: {crypto}<br>" cryptoHTML += f"<br>Invalid chain: {crypto}<br>"
if os.path.isfile(".well-known/wallets/.domains"): domains = get_wallet_domains()
# Get json of all domains
with open(".well-known/wallets/.domains") as file:
domains = file.read()
domains = json.loads(domains)
if crypto in domains: if crypto in domains:
domain = domains[crypto] domain = domains[crypto]
cryptoHTML += "<br>Or send to this domain on compatible wallets:<br>" cryptoHTML += "<br>Or send to this domain on compatible wallets:<br>"
cryptoHTML += f'<code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-domain" class="address" style="color: rgb(242,90,5);display: block;" data-bs-original-title="Click to copy">{domain}</code>' cryptoHTML += f'<code data-bs-toggle="tooltip" data-bss-tooltip="" id="crypto-domain" class="address" style="color: rgb(242,90,5);display: block;" data-bs-original-title="Click to copy">{domain}</code>'
if address: if address:
cryptoHTML += ( cryptoHTML += (
'<br><img src="/address/' '<br><img src="/address/'
@@ -540,29 +472,33 @@ def qraddress(address):
@app.route("/qrcode/<path:data>") @app.route("/qrcode/<path:data>")
@app.route("/qr/<path:data>") @app.route("/qr/<path:data>")
def qrcodee(data): def qrcodee(data):
qr = qrcode.QRCode( qr = qrcode.QRCode(error_correction=ERROR_CORRECT_H, box_size=10, border=2)
error_correction=ERROR_CORRECT_H, box_size=10, border=2)
qr.add_data(data) qr.add_data(data)
qr.make() qr.make()
qr_image: Image.Image = qr.make_image( qr_image: Image.Image = qr.make_image(
fill_color="black", back_color="white").convert('RGB') # type: ignore fill_color="black", back_color="white"
).convert("RGB") # type: ignore
# Add logo # Add logo
logo = Image.open("templates/assets/img/favicon/logo.png") logo = Image.open("templates/assets/img/favicon/logo.png")
basewidth = qr_image.size[0] // 3 basewidth = qr_image.size[0] // 3
wpercent = (basewidth / float(logo.size[0])) wpercent = basewidth / float(logo.size[0])
hsize = int((float(logo.size[1]) * float(wpercent))) hsize = int((float(logo.size[1]) * float(wpercent)))
logo = logo.resize((basewidth, hsize), Image.Resampling.LANCZOS) logo = logo.resize((basewidth, hsize), Image.Resampling.LANCZOS)
pos = ((qr_image.size[0] - logo.size[0]) // 2, pos = (
(qr_image.size[1] - logo.size[1]) // 2) (qr_image.size[0] - logo.size[0]) // 2,
(qr_image.size[1] - logo.size[1]) // 2,
)
qr_image.paste(logo, pos, mask=logo) qr_image.paste(logo, pos, mask=logo)
qr_image.save("/tmp/qr_code.png") qr_image.save("/tmp/qr_code.png")
return send_file("/tmp/qr_code.png", mimetype="image/png") return send_file("/tmp/qr_code.png", mimetype="image/png")
# endregion # endregion
@app.route("/supersecretpath") @app.route("/supersecretpath")
def supersecretpath(): def supersecretpath():
ascii_art = "" ascii_art = ""
@@ -599,15 +535,18 @@ def hosting_post():
# Check email rate limit # Check email rate limit
if email in EMAIL_REQUEST_COUNT: if email in EMAIL_REQUEST_COUNT:
if (current_time - EMAIL_REQUEST_COUNT[email]["last_reset"]) > RATE_LIMIT_WINDOW: if (
current_time - EMAIL_REQUEST_COUNT[email]["last_reset"]
) > RATE_LIMIT_WINDOW:
# Reset counter if the time window has passed # Reset counter if the time window has passed
EMAIL_REQUEST_COUNT[email] = { EMAIL_REQUEST_COUNT[email] = {"count": 1, "last_reset": current_time}
"count": 1, "last_reset": current_time}
else: else:
# Increment counter # Increment counter
EMAIL_REQUEST_COUNT[email]["count"] += 1 EMAIL_REQUEST_COUNT[email]["count"] += 1
if EMAIL_REQUEST_COUNT[email]["count"] > EMAIL_RATE_LIMIT: if EMAIL_REQUEST_COUNT[email]["count"] > EMAIL_RATE_LIMIT:
return json_response(request, "Rate limit exceeded. Please try again later.", 429) return json_response(
request, "Rate limit exceeded. Please try again later.", 429
)
else: else:
# First request for this email # First request for this email
EMAIL_REQUEST_COUNT[email] = {"count": 1, "last_reset": current_time} EMAIL_REQUEST_COUNT[email] = {"count": 1, "last_reset": current_time}
@@ -621,7 +560,9 @@ def hosting_post():
# Increment counter # Increment counter
IP_REQUEST_COUNT[ip]["count"] += 1 IP_REQUEST_COUNT[ip]["count"] += 1
if IP_REQUEST_COUNT[ip]["count"] > IP_RATE_LIMIT: if IP_REQUEST_COUNT[ip]["count"] > IP_RATE_LIMIT:
return json_response(request, "Rate limit exceeded. Please try again later.", 429) return json_response(
request, "Rate limit exceeded. Please try again later.", 429
)
else: else:
# First request for this IP # First request for this IP
IP_REQUEST_COUNT[ip] = {"count": 1, "last_reset": current_time} IP_REQUEST_COUNT[ip] = {"count": 1, "last_reset": current_time}
@@ -681,33 +622,41 @@ def hosting_post():
return json_response(request, "Enquiry sent", 200) return json_response(request, "Enquiry sent", 200)
@app.route("/resume")
def resume():
# Check if arg for support is passed
support = request.args.get("support")
return render_template("resume.html", support=support)
@app.route("/resume.pdf") @app.route("/resume.pdf")
def resume_pdf(): def resume_pdf():
# Check if file exists # Check if arg for support is passed
if os.path.isfile("data/resume.pdf"): support = request.args.get("support")
if support:
return send_file("data/resume_support.pdf")
return send_file("data/resume.pdf") return send_file("data/resume.pdf")
return error_response(request, message="Resume not found")
@app.route("/tools") @app.route("/tools")
def tools(): def tools():
if isCurl(request): if isCLI(request):
return curl_response(request) return curl_response(request)
return render_template("tools.html", tools=get_tools_data()) return render_template("tools.html", tools=get_tools_data())
# endregion # endregion
# region Error Catching # region Error Catching
# Catch all for GET requests # Catch all for GET requests
@app.route("/<path:path>") @app.route("/<path:path>")
def catch_all(path: str): def catch_all(path: str):
if path.lower().replace(".html", "") in RESTRICTED_ROUTES: if path.lower().replace(".html", "") in RESTRICTED_ROUTES:
return error_response(request, message="Restricted route", code=403) return error_response(request, message="Restricted route", code=403)
# If curl request, return curl response # If curl request, return curl response
if isCurl(request) and valid_curl_path(path): if isCLI(request):
return curl_response(request) return curl_response(request)
if path in REDIRECT_ROUTES: if path in REDIRECT_ROUTES:
@@ -715,17 +664,23 @@ def catch_all(path: str):
# If file exists, load it # If file exists, load it
if os.path.isfile("templates/" + path): if os.path.isfile("templates/" + path):
return render_template(path, handshake_scripts=getHandshakeScript(request.host), sites=SITES) return render_template(
path, handshake_scripts=getHandshakeScript(request.host), sites=SITES
)
# Try with .html # Try with .html
if os.path.isfile("templates/" + path + ".html"): if os.path.isfile("templates/" + path + ".html"):
return render_template( return render_template(
path + ".html", handshake_scripts=getHandshakeScript(request.host), sites=SITES path + ".html",
handshake_scripts=getHandshakeScript(request.host),
sites=SITES,
) )
if os.path.isfile("templates/" + path.strip("/") + ".html"): if os.path.isfile("templates/" + path.strip("/") + ".html"):
return render_template( return render_template(
path.strip("/") + ".html", handshake_scripts=getHandshakeScript(request.host), sites=SITES path.strip("/") + ".html",
handshake_scripts=getHandshakeScript(request.host),
sites=SITES,
) )
# Try to find a file matching # Try to find a file matching
@@ -742,6 +697,7 @@ def catch_all(path: str):
def not_found(e): def not_found(e):
return error_response(request) return error_response(request)
# endregion # endregion

View File

@@ -1 +1 @@
.name-container{display:inline-flex;align-items:center;overflow:hidden;position:absolute;width:fit-content;left:50%;transform:translateX(-50%)}.slider{position:relative;left:0;animation:1s linear 1s forwards slide}@keyframes slide{0%{left:0}100%{left:calc(100%)}}.brand{mask-image:linear-gradient(to right,black 50%,transparent 50%);-webkit-mask-image:linear-gradient(to right,black 50%,transparent 50%);mask-position:100% 0;-webkit-mask-position:100% 0;mask-size:200%;-webkit-mask-size:200%;animation:1s linear 1s forwards reveal}@keyframes reveal{0%{mask-position:100% 0;-webkit-mask-position:100% 0}100%{mask-position:0 0;-webkit-mask-position:0 0}}.now-playing{position:fixed;bottom:0;right:0;border-top-left-radius:10px;background:#10101039;padding:1em} .name-container{display:inline-flex;align-items:center;overflow:hidden;position:absolute;width:fit-content;left:50%;transform:translateX(-50%)}.slider{position:relative;left:0;animation:1s linear 1s forwards slide}@keyframes slide{0%{left:0}100%{left:calc(100%)}}.brand{mask-image:linear-gradient(to right,black 50%,transparent 50%);-webkit-mask-image:linear-gradient(to right,black 50%,transparent 50%);mask-position:100% 0;-webkit-mask-position:100% 0;mask-size:200%;-webkit-mask-size:200%;animation:1s linear 1s forwards reveal}@keyframes reveal{0%{mask-position:100% 0;-webkit-mask-position:100% 0}100%{mask-position:0 0;-webkit-mask-position:0 0}}.now-playing{position:fixed;bottom:0;right:0;border-top-left-radius:10px;background:#10101039;padding:1em}.hr-l{width:80%;border-width:2px;border-color:var(--bs-light);margin-top:0;opacity:.8}.hr-l-primary{border-width:3px;border-color:var(--bs-primary);margin-top:0;opacity:1}.float-right{position:absolute;right:3em}

View File

@@ -0,0 +1,104 @@
/* print.css */
@media print {
/* Page margins */
@page {
size: A4;
margin: 10mm 10mm;
}
/* Reset body */
body, html {
margin: 0;
padding: 0;
font-size: 10pt; /* smaller for print */
line-height: 1.3;
background: #fff !important;
color: #000 !important;
}
/* Container adjustments */
.container-fluid, .resume-row, .resume-column {
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box;
/* background: none !important; */
}
/* Flex layout for 33/66 split */
.resume-row {
display: flex !important;
flex-wrap: nowrap !important;
width: 100% !important;
}
.resume-column-left {
flex: 0 0 33.3333% !important;
max-width: 33.3333% !important;
padding-left: 5mm !important;
padding-right: 5mm !important;
color: #fff !important;
border: none !important;
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.resume-column-left a {
color: #fff !important;
text-decoration: none !important;
}
.resume-column-right {
flex: 0 0 66.6667% !important;
max-width: 66.6667% !important;
padding-left: 5mm !important;
padding-right: 5mm !important;
background: #fff !important;
color: #000 !important;
border: none !important;
break-inside: avoid !important;
page-break-inside: avoid !important;
}
/* Images adjustments */
img {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10mm auto !important;
}
/* Text adjustments for print */
h1 { font-size: 14pt; margin-bottom: 3mm; }
h2 { font-size: 12pt; margin-bottom: 2mm; }
h3 { font-size: 11pt; margin-bottom: 2mm; }
h4 { font-size: 10pt; margin-bottom: 1mm; }
h5, h6 { font-size: 9pt; margin-bottom: 1mm; }
p, li, .r-body, .l-body { font-size: 10pt; line-height: 1.3; }
.title {
font-size: 36px !important;
}
.subtitle {
font-size: 18px !important;
}
.r-heading1 {
margin-top: 4mm !important;
}
/* Links as plain text */
a {
color: #000 !important;
text-decoration: none !important;
}
/* Avoid page breaks inside blocks */
.noprintbreak {
break-inside: avoid !important;
page-break-inside: avoid !important;
/* margin-bottom: 5mm !important; */
}
.r-body {
margin-bottom: 0 !important;
}
}

View File

@@ -1 +1 @@
.profile-container{height:170px;width:170px;z-index:2;left:10%}.title{position:absolute;margin-left:calc(100px);width:calc(100% - 100px);padding:1em;margin-top:-225px;z-index:0}.title>*{width:100%;margin-bottom:0}img.profile{left:10px;width:150px;position:absolute;aspect-ratio:1;transform:scale(1);transition:.5s;z-index:2}img.background2{left:0;width:170px!important;margin-top:-10px;pointer-events:none;z-index:1}img.foreground{border-radius:50%;pointer-events:none;z-index:3}img.background:hover,img.backgroundsml:hover{filter:blur(5px)}.spacer{height:100px}img.profilesml{width:150px;position:absolute;left:50%;margin-left:-85px;aspect-ratio:1;padding-top:calc(var(--s)/5);transform:scale(1);transition:.5s}img.foregroundsml{border-radius:50%;pointer-events:none}img.background2sml{width:170px!important;left:calc(50% - 10px);margin-top:-10px;pointer-events:none;z-index:0}print_text{color:#000!important}@media print{.noprintbreak{page-break-inside:avoid}*{color:#000;background-color:#fff}body{background-color:#fff}.hideprint{display:none}.print_text{color:#000!important}.profile-container{margin-top:10px!important}.r-heading1{font-size:16pt!important;margin-bottom:10px!important}.r-heading2{font-size:14pt!important}.r-heading3{font-size:12pt!important}.r-body,.r-small{font-size:10pt!important}.spacer{height:25px!important}}.r-heading1{margin-bottom:20px}.r-heading2{margin-bottom:0}.r-heading3{margin-bottom:.5em}@media (max-width:500px){.print_text{font-size:10px}} img.profile-side{width:200px;aspect-ratio:1;z-index:2;border:6px solid #fff;margin:3em 0;border-radius:50%}.spacer{height:100px}.l-heading1,.l-heading2,.r-heading2{margin-bottom:0}.l-heading3,.r-heading3{margin-bottom:.5em}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{text-transform:none}.side-column{margin-top:2em}.noprintbreak{margin-bottom:1.5em}.resume-column-left{background:var(--bs-primary);padding-left:3em;padding-right:3em;max-width:320px}.resume-column-right{padding-right:3em;padding-left:3em;background:var(--bs-light);color:var(--bs-black)}.row-fill div{padding:0}.r-heading1{font-size:28px;margin-bottom:0;color:var(--bs-primary)}.title-hr{width:15%;color:var(--bs-primary);border-width:5px;border-color:var(--bs-primary);opacity:1}.l-body{margin-left:1em;line-height:initial}.r-body{line-height:initial}.l-summary{margin-top:3em}::selection{color:#fff;background-color:#0c4279}body{max-width:1400px;margin:0 auto}

View File

@@ -1 +1 @@
:root,[data-bs-theme=light]{--bs-primary:#6E0E9C;--bs-primary-rgb:110,14,156;--bs-primary-text-emphasis:#2C063E;--bs-primary-bg-subtle:#E2CFEB;--bs-primary-border-subtle:#C59FD7;--bs-link-color:#6E0E9C;--bs-link-color-rgb:110,14,156;--bs-link-hover-color:#a41685;--bs-link-hover-color-rgb:164,22,133}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5E0C85;--bs-btn-hover-border-color:#580B7D;--bs-btn-focus-shadow-rgb:233,219,240;--bs-btn-active-color:#fff;--bs-btn-active-bg:#580B7D;--bs-btn-active-border-color:#530B75;--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6E0E9C;--bs-btn-disabled-border-color:#6E0E9C}.btn-outline-primary{--bs-btn-color:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-focus-shadow-rgb:110,14,156;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6E0E9C;--bs-btn-hover-border-color:#6E0E9C;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6E0E9C;--bs-btn-active-border-color:#6E0E9C;--bs-btn-disabled-color:#6E0E9C;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6E0E9C}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}@media (min-width:992px){.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}} :root,[data-bs-theme=light]{--bs-primary:#6E0E9C;--bs-primary-rgb:110,14,156;--bs-primary-text-emphasis:#2C063E;--bs-primary-bg-subtle:#E2CFEB;--bs-primary-border-subtle:#C59FD7;--bs-link-color:#6E0E9C;--bs-link-color-rgb:110,14,156;--bs-link-hover-color:#a41685;--bs-link-hover-color-rgb:164,22,133}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5E0C85;--bs-btn-hover-border-color:#580B7D;--bs-btn-focus-shadow-rgb:233,219,240;--bs-btn-active-color:#fff;--bs-btn-active-bg:#580B7D;--bs-btn-active-border-color:#530B75;--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6E0E9C;--bs-btn-disabled-border-color:#6E0E9C}.btn-outline-primary{--bs-btn-color:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-focus-shadow-rgb:110,14,156;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6E0E9C;--bs-btn-hover-border-color:#6E0E9C;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6E0E9C;--bs-btn-active-border-color:#6E0E9C;--bs-btn-disabled-color:#6E0E9C;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6E0E9C}

1
templates/assets/css/tools.min.css vendored Normal file
View File

@@ -0,0 +1 @@
.card:hover{transform:translateY(-5px);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);transition:transform .2s,box-shadow .2s}.btn:hover{transform:scale(1.05);transition:transform .2s}

9
templates/error.ascii Normal file
View File

@@ -0,0 +1,9 @@
{{header}}
───────────────────────────────────────────────
 ERROR: {{ error.code }} 
────────────
{{ error.message }}
If you believe this is an error, please contact me via my socials listed at /contact

View File

@@ -9,4 +9,5 @@ Contact [/contact]
Projects [/projects] Projects [/projects]
Tools [/tools] Tools [/tools]
Donate [/donate] Donate [/donate]
Now [/now]

View File

@@ -9,7 +9,8 @@ Contact [/contact]
Projects [/projects] Projects [/projects]
Tools [/tools] Tools [/tools]
Donate [/donate] Donate [/donate]
API [/api/v1/] Now [/now]
API [/api/v1]
─────────────────────────────────────────────── ───────────────────────────────────────────────
 ABOUT ME   ABOUT ME 

View File

@@ -294,7 +294,7 @@ Check them out here!</blockquote><img class="img-fluid" src="/assets/img/pfront.
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18ZM11 6C11 5.44772 10.5523 5 10 5C9.44771 5 9 5.44772 9 6V10C9 10.2652 9.10536 10.5196 9.29289 10.7071L12.1213 13.5355C12.5118 13.9261 13.145 13.9261 13.5355 13.5355C13.9261 13.145 13.9261 12.5118 13.5355 12.1213L11 9.58579V6Z" fill="currentColor"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18ZM11 6C11 5.44772 10.5523 5 10 5C9.44771 5 9 5.44772 9 6V10C9 10.2652 9.10536 10.5196 9.29289 10.7071L12.1213 13.5355C12.5118 13.9261 13.145 13.9261 13.5355 13.5355C13.9261 13.145 13.9261 12.5118 13.5355 12.1213L11 9.58579V6Z" fill="currentColor"></path>
</svg><span style="margin-left: 10px;font-family: 'Anonymous Pro', monospace;">{{time|safe}}</span></div><!-- Pop-out button for mobile --> </svg><span style="margin-left: 10px;font-family: 'Anonymous Pro', monospace;">{{time|safe}}</span></div><!-- Pop-out button for mobile -->
<button id="spotify-toggle" style=" <button id="spotify-toggle" style="
display: none; display: block;
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -305,6 +305,8 @@ Check them out here!</blockquote><img class="img-fluid" src="/assets/img/pfront.
background: none; background: none;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
transition: transform 0.5s ease;
transform: translateX(200%); /* start hidden off-screen *
"> ">
<img src="/assets/img/external/spotify.png" alt="Spotify" style=" <img src="/assets/img/external/spotify.png" alt="Spotify" style="
width: 100%; width: 100%;
@@ -360,6 +362,21 @@ Check them out here!</blockquote><img class="img-fluid" src="/assets/img/pfront.
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
"></div> "></div>
<!-- Progress Bar -->
<div style="
margin-top: 6px;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
">
<div id="spotify-progress" style="
width: 0%;
height: 100%;
background: #1DB954;
transition: width 1s linear;
"></div>
</div>
</div> </div>
</div> </div>
@@ -374,16 +391,17 @@ function isMobile() {
function updateVisibility() { function updateVisibility() {
if(isMobile()){ if(isMobile()){
widget.style.transform = 'translateX(120%)'; // hidden off-screen widget.style.transform = 'translateX(120%)'; // hidden off-screen
toggleBtn.style.display = 'block'; toggleBtn.style.transform = 'translateX(0)'; // visible
} else { } else {
widget.style.transform = 'translateX(0)'; // visible widget.style.transform = 'translateX(0)'; // visible
toggleBtn.style.display = 'none'; toggleBtn.style.transform = 'translateX(200%)'; // hidden off-screen
} }
} }
// Toggle widget slide in/out on mobile // Toggle widget slide in/out on mobile
toggleBtn.addEventListener('click', (e) => { toggleBtn.addEventListener('click', (e) => {
widget.style.transform = 'translateX(0)'; // slide in widget.style.transform = 'translateX(0)'; // slide in
toggleBtn.style.transform = 'translateX(200%)'; // hide button
e.stopPropagation(); e.stopPropagation();
}); });
@@ -392,6 +410,7 @@ document.addEventListener('click', (e) => {
if(isMobile()){ if(isMobile()){
if(!widget.contains(e.target) && e.target !== toggleBtn){ if(!widget.contains(e.target) && e.target !== toggleBtn){
widget.style.transform = 'translateX(120%)'; // slide out widget.style.transform = 'translateX(120%)'; // slide out
toggleBtn.style.transform = 'translateX(0)'; // show button
} }
} }
}); });
@@ -401,10 +420,19 @@ widget.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
}); });
// Variable to track progress bar animation
let progressInterval = null;
let progressSpeed = 0;
let lastUpdateTime = Date.now();
let currentProgress = 0;
let targetProgress = 0;
let trackDuration = 0;
let currentTrackId = null;
// --- Spotify fetch --- // --- Spotify fetch ---
async function updateSpotifyWidget() { async function updateSpotifyWidget() {
try { try {
const res = await fetch('/spotify/'); const res = await fetch('/api/v1/playing');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
@@ -419,6 +447,11 @@ async function updateSpotifyWidget() {
document.getElementById('spotify-song').textContent = 'Not Playing'; document.getElementById('spotify-song').textContent = 'Not Playing';
document.getElementById('spotify-artist').textContent = ''; document.getElementById('spotify-artist').textContent = '';
document.getElementById('spotify-album').textContent = ''; document.getElementById('spotify-album').textContent = '';
document.getElementById('spotify-progress').style.width = '0%';
clearInterval(progressInterval);
progressInterval = null;
currentProgress = 0;
currentTrackId = null;
return; return;
} }
@@ -429,13 +462,52 @@ async function updateSpotifyWidget() {
firstLoad = true; firstLoad = true;
} }
// Check if track has changed (new song started)
const trackId = track.song_name + track.artist; // Simple track identifier
const isNewTrack = currentTrackId !== null && currentTrackId !== trackId;
if (isNewTrack) {
// Reset progress bar instantly for new track
currentProgress = 0;
document.getElementById('spotify-progress').style.transition = 'none';
document.getElementById('spotify-progress').style.width = '0%';
// Force reflow to apply the instant reset
document.getElementById('spotify-progress').offsetHeight;
// Re-enable transition
document.getElementById('spotify-progress').style.transition = 'width 0.1s linear';
}
currentTrackId = trackId;
document.getElementById('spotify-album-art').src = track.album_art; document.getElementById('spotify-album-art').src = track.album_art;
document.getElementById('spotify-song').textContent = track.song_name; document.getElementById('spotify-song').textContent = track.song_name;
document.getElementById('spotify-artist').textContent = track.artist; document.getElementById('spotify-artist').textContent = track.artist;
document.getElementById('spotify-album').textContent = track.album_name; document.getElementById('spotify-album').textContent = track.album_name;
// Update progress bar
if (track.is_playing) {
currentProgress = (track.progress_ms / track.duration_ms) * 100;
trackDuration = track.duration_ms;
lastUpdateTime = Date.now();
document.getElementById('spotify-progress').style.width = currentProgress + '%';
// Clear existing interval
if (progressInterval) {
clearInterval(progressInterval);
}
// Start interval to animate progress bar
progressInterval = setInterval(animateProgressBar, 100);
} else {
document.getElementById('spotify-progress').style.width = currentProgress + '%';
clearInterval(progressInterval);
progressInterval = null;
}
// If first load and desktop, slide in the widget
if (firstLoad) { if (firstLoad) {
widget.style.transform = 'translateX(0)'; // slide in on first load updateVisibility();
} }
} catch (err) { } catch (err) {
@@ -443,6 +515,30 @@ async function updateSpotifyWidget() {
} }
} }
// Animate progress bar
function animateProgressBar() {
if (trackDuration === 0) return;
const now = Date.now();
const elapsed = now - lastUpdateTime;
lastUpdateTime = now;
// Calculate progress increment based on elapsed time
const progressIncrement = (elapsed / trackDuration) * 100;
currentProgress += progressIncrement;
if (currentProgress >= 100) {
currentProgress = 100;
document.getElementById('spotify-progress').style.width = '100%';
clearInterval(progressInterval);
progressInterval = null;
// Refresh API when progress reaches 100%
setTimeout(updateSpotifyWidget, 500);
} else {
document.getElementById('spotify-progress').style.width = currentProgress + '%';
}
}
// Wait for Spotify API to have responded before initial display // Wait for Spotify API to have responded before initial display
updateSpotifyWidget(); updateSpotifyWidget();

6
templates/now.ascii Normal file
View File

@@ -0,0 +1,6 @@
{{header}}
───────────────────────────────────────────────
 Now {{ date }} 
────────────
{{content | safe}}

167
templates/now/25_11_20.html Normal file
View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en-au" style="background: black;height: auto;">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>What's up at the moment | Nathan.Woodburn/</title>
<meta name="theme-color" content="#000000">
<link rel="canonical" href="https://nathan.woodburn.au/now/25_11_20">
<meta property="og:url" content="https://nathan.woodburn.au/now/25_11_20">
<meta name="fediverse:creator" content="@nathanwoodburn@mastodon.woodburn.au">
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="https://nathan.woodburn.au/assets/img/profile.jpg">
<meta property="og:type" content="website">
<meta property="og:image" content="https://nathan.woodburn.au/assets/img/profile.jpg">
<meta property="og:description" content="G'day,
Find out what I've been up to in the last little bit">
<meta name="twitter:title" content="What's up at the moment | Nathan.Woodburn/">
<meta property="og:title" content="What's up at the moment | Nathan.Woodburn/">
<meta name="description" content="G'day,
Find out what I've been up to in the last little bit">
<meta name="twitter:description" content="G'day,
Find out what I've been up to in the last little bit">
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="/assets/img/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="180x180" href="/assets/img/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/assets/img/favicon/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/assets/img/favicon/android-chrome-512x512.png">
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css">
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Cabin:700&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Anonymous+Pro&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&amp;display=swap">
<link rel="stylesheet" href="/assets/fonts/font-awesome.min.css">
<link rel="stylesheet" href="/assets/fonts/ionicons.min.css">
<link rel="stylesheet" href="/assets/css/styles.min.css">
<link rel="stylesheet" href="/assets/css/brand-reveal.min.css">
<link rel="stylesheet" href="/assets/css/profile.min.css">
<link rel="stylesheet" href="/assets/css/Social-Icons.min.css">
<link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" />
<script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script>
</head>
<body class="text-center" style="background: linear-gradient(rgba(0,0,0,0.80), rgba(0,0,0,0.80)), url(&quot;/assets/img/bg/background.webp&quot;) center / cover no-repeat;">
<nav class="navbar navbar-expand-md fixed-top navbar-light" id="mainNav" style="background: var(--bs-navbar-hover-color);">
<div class="container-fluid"><a class="navbar-brand" href="/#">
<div style="padding-right: 1em;display: inline-flex;">
<div class="slider"><span>/</span></div><span class="brand">Nathan.Woodburn</span>
</div>
</a><button data-bs-toggle="collapse" class="navbar-toggler navbar-toggler-right" data-bs-target="#navbarResponsive" type="button" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation" value="Menu"><i class="fa fa-bars"></i></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ms-auto">
<li class="nav-item nav-link"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/hosting">Hosting</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/projects">Projects</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/tools">Tools</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/blog">Blog</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/now">Now</a></li>
</ul>
</div>
</div>
</nav>{{handshake_scripts | safe}}
<div style="height: 10em;"></div>
<div class="profile-container" style="margin-bottom: 2em;"><img class="profile background" src="/assets/img/profile.jpg" style="border-radius: 50%;"><img class="profile foreground" src="/assets/img/pfront.webp"></div>
<h1 class="nathanwoodburn" style="margin-bottom: 0px;">Nathan.Woodburn/</h1>
<h3 style="margin-bottom: 0px;">WHat's Happening Now</h3>
<h6>{{DATE}}</h6>
<section style="margin-bottom: 50px;max-width: 95%;margin-right: auto;margin-left: auto;">
<div style="max-width: 700px;margin: auto;">
<h1 style="margin-bottom: 0px;">Starting at CSIRO</h1>
<p>Im excited to share that I'm starting a new position at CSIRO as a Web Hosting System Administrator. Its a role that sits right at the intersection of technology, security, and supporting the research happening across the organisation.</p>
</div>
</section>
<section style="margin-bottom: 50px;max-width: 95%;margin-right: auto;margin-left: auto;">
<div style="max-width: 700px;margin: auto;">
<h1 style="margin-bottom: 0px;">Website Updates</h1>
<p>I've updated my python3 flask website code to use UV for the package manager. It has cut down the initial install and startup from over 30s to under 10. This also makes building the docker image quicker and more consistent.</p>
</div>
</section>
<section class="text-center content-section" id="contact" style="padding-top: 0px;padding-bottom: 3em;">
<div class="container">
<div class="row">
<div class="col-lg-8 d-none d-print-block d-sm-block d-md-block d-lg-block d-xl-block d-xxl-block mx-auto">
<div class="social-div">
<ul class="list-unstyled social-list">
<li class="social-link"><a href="https://twitter.com/woodburn_nathan" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-twitter-x icon">
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z"></path>
</svg></a></li>
<li class="social-link"><a href="https://github.com/Nathanwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github icon">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path>
</svg></a></li>
<li class="social-link"><a href="mailto:about@nathan.woodburn.au" target="_blank"><i class="icon ion-email icon"></i></a></li>
<li class="social-link discord"><a href="https://l.woodburn.au/discord" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-discord icon">
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"></path>
</svg></a></li>
</ul>
</div>
<div class="social-div">
<ul class="list-unstyled social-list">
<li class="social-link mastodon"><a href="https://mastodon.woodburn.au/@nathanwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-mastodon icon">
<path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"></path>
</svg></a></li>
<li class="social-link youtube"><a href="https://www.youtube.com/@nathanjwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-youtube icon">
<path d="M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 0 1 1.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.007 2.007 0 0 1-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.007 2.007 0 0 1-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31.4 31.4 0 0 1 0 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.007 2.007 0 0 1 1.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A99.788 99.788 0 0 1 7.858 2h.193zM6.4 5.209v4.818l4.157-2.408z"></path>
</svg></a></li>
<li class="social-link signal"><a href="/signalQR" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-signal icon">
<path d="m6.08.234.179.727a7.264 7.264 0 0 0-2.01.832l-.383-.643A7.9 7.9 0 0 1 6.079.234zm3.84 0L9.742.96a7.265 7.265 0 0 1 2.01.832l.388-.643A7.957 7.957 0 0 0 9.92.234zm-8.77 3.63a7.944 7.944 0 0 0-.916 2.215l.727.18a7.264 7.264 0 0 1 .832-2.01l-.643-.386zM.75 8a7.3 7.3 0 0 1 .081-1.086L.091 6.8a8 8 0 0 0 0 2.398l.74-.112A7.262 7.262 0 0 1 .75 8m11.384 6.848-.384-.64a7.23 7.23 0 0 1-2.007.831l.18.728a7.965 7.965 0 0 0 2.211-.919zM15.251 8c0 .364-.028.727-.082 1.086l.74.112a7.966 7.966 0 0 0 0-2.398l-.74.114c.054.36.082.722.082 1.086m.516 1.918-.728-.18a7.252 7.252 0 0 1-.832 2.012l.643.387a7.933 7.933 0 0 0 .917-2.219zm-6.68 5.25c-.72.11-1.453.11-2.173 0l-.112.742a7.99 7.99 0 0 0 2.396 0l-.112-.741zm4.75-2.868a7.229 7.229 0 0 1-1.537 1.534l.446.605a8.07 8.07 0 0 0 1.695-1.689l-.604-.45zM12.3 2.163c.587.432 1.105.95 1.537 1.537l.604-.45a8.06 8.06 0 0 0-1.69-1.691l-.45.604zM2.163 3.7A7.242 7.242 0 0 1 3.7 2.163l-.45-.604a8.06 8.06 0 0 0-1.691 1.69l.604.45zm12.688.163-.644.387c.377.623.658 1.3.832 2.007l.728-.18a7.931 7.931 0 0 0-.916-2.214M6.913.831a7.254 7.254 0 0 1 2.172 0l.112-.74a7.985 7.985 0 0 0-2.396 0l.112.74zM2.547 14.64 1 15l.36-1.549-.729-.17-.361 1.548a.75.75 0 0 0 .9.902l1.548-.357-.17-.734zM.786 12.612l.732.168.25-1.073A7.187 7.187 0 0 1 .96 9.74l-.727.18a8 8 0 0 0 .736 1.902l-.184.79zm3.5 1.623-1.073.25.17.731.79-.184c.6.327 1.239.574 1.902.737l.18-.728a7.197 7.197 0 0 1-1.962-.811l-.007.005zM8 1.5a6.502 6.502 0 0 0-6.498 6.502 6.516 6.516 0 0 0 .998 3.455l-.625 2.668L4.54 13.5a6.502 6.502 0 0 0 6.93-11A6.516 6.516 0 0 0 8 1.5"></path>
</svg></a></li>
</ul>
</div>
</div>
<div class="col-lg-8 d-block d-print-none d-sm-none d-md-none d-lg-none d-xl-none d-xxl-none mx-auto">
<div class="social-div">
<ul class="list-unstyled social-list-sml">
<li class="social-link-sml"><a href="https://twitter.com/woodburn_nathan" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-twitter-x icon-sml">
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z"></path>
</svg></a></li>
<li class="social-link-sml"><a href="https://github.com/Nathanwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github icon-sml">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path>
</svg></a></li>
<li class="social-link-sml"><a href="mailto:about@nathan.woodburn.au" target="_blank"><i class="icon ion-email icon-sml"></i></a></li>
<li class="discord social-link-sml"><a href="https://l.woodburn.au/discord" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-discord icon-sml">
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"></path>
</svg></a></li>
</ul>
</div>
<div class="social-div">
<ul class="list-unstyled social-list-sml">
<li class="mastodon social-link-sml"><a href="https://mastodon.woodburn.au/@nathanwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-mastodon icon-sml">
<path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"></path>
</svg></a></li>
<li class="youtube social-link-sml"><a href="https://www.youtube.com/@nathanjwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-youtube icon-sml">
<path d="M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 0 1 1.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.007 2.007 0 0 1-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.007 2.007 0 0 1-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31.4 31.4 0 0 1 0 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.007 2.007 0 0 1 1.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A99.788 99.788 0 0 1 7.858 2h.193zM6.4 5.209v4.818l4.157-2.408z"></path>
</svg></a></li>
<li class="signal social-link-sml"><a href="/signalQR" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-signal icon-sml">
<path d="m6.08.234.179.727a7.264 7.264 0 0 0-2.01.832l-.383-.643A7.9 7.9 0 0 1 6.079.234zm3.84 0L9.742.96a7.265 7.265 0 0 1 2.01.832l.388-.643A7.957 7.957 0 0 0 9.92.234zm-8.77 3.63a7.944 7.944 0 0 0-.916 2.215l.727.18a7.264 7.264 0 0 1 .832-2.01l-.643-.386zM.75 8a7.3 7.3 0 0 1 .081-1.086L.091 6.8a8 8 0 0 0 0 2.398l.74-.112A7.262 7.262 0 0 1 .75 8m11.384 6.848-.384-.64a7.23 7.23 0 0 1-2.007.831l.18.728a7.965 7.965 0 0 0 2.211-.919zM15.251 8c0 .364-.028.727-.082 1.086l.74.112a7.966 7.966 0 0 0 0-2.398l-.74.114c.054.36.082.722.082 1.086m.516 1.918-.728-.18a7.252 7.252 0 0 1-.832 2.012l.643.387a7.933 7.933 0 0 0 .917-2.219zm-6.68 5.25c-.72.11-1.453.11-2.173 0l-.112.742a7.99 7.99 0 0 0 2.396 0l-.112-.741zm4.75-2.868a7.229 7.229 0 0 1-1.537 1.534l.446.605a8.07 8.07 0 0 0 1.695-1.689l-.604-.45zM12.3 2.163c.587.432 1.105.95 1.537 1.537l.604-.45a8.06 8.06 0 0 0-1.69-1.691l-.45.604zM2.163 3.7A7.242 7.242 0 0 1 3.7 2.163l-.45-.604a8.06 8.06 0 0 0-1.691 1.69l.604.45zm12.688.163-.644.387c.377.623.658 1.3.832 2.007l.728-.18a7.931 7.931 0 0 0-.916-2.214M6.913.831a7.254 7.254 0 0 1 2.172 0l.112-.74a7.985 7.985 0 0 0-2.396 0l.112.74zM2.547 14.64 1 15l.36-1.549-.729-.17-.361 1.548a.75.75 0 0 0 .9.902l1.548-.357-.17-.734zM.786 12.612l.732.168.25-1.073A7.187 7.187 0 0 1 .96 9.74l-.727.18a8 8 0 0 0 .736 1.902l-.184.79zm3.5 1.623-1.073.25.17.731.79-.184c.6.327 1.239.574 1.902.737l.18-.728a7.197 7.197 0 0 1-1.962-.811l-.007.005zM8 1.5a6.502 6.502 0 0 0-6.498 6.502 6.516 6.516 0 0 0 .998 3.455l-.625 2.668L4.54 13.5a6.502 6.502 0 0 0 6.93-11A6.516 6.516 0 0 0 8 1.5"></path>
</svg></a></li>
</ul>
</div>
</div>
</div>
</div>
</section>
<footer style="background: #110033;">
<div class="container text-center">
<div class="row">
<div class="col">
<p class="d-none d-print-inline-block d-sm-inline-block d-md-inline-block d-lg-inline-block d-xl-inline-block d-xxl-inline-block">Want to look at some past Now pages?<br>Check out <a href="/old">/old</a></p>
</div>
</div>
<div class="row">
<div class="col">
<p class="d-none d-print-inline-block d-sm-inline-block d-md-inline-block d-lg-inline-block d-xl-inline-block d-xxl-inline-block">This site is also available on<br><a href="https://learn.namebase.io/" target="_blank">Handshake</a>&nbsp;at <a href="https://nathan.woodburn">https://nathan.woodburn/</a></p>
<p class="copyright">Copyright ©&nbsp;Nathan.Woodburn/ 2025</p>
</div>
</div>
</div>
</footer>
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
<script src="/assets/js/script.min.js"></script>
<script src="/assets/js/grayscale.min.js"></script>
<script src="/assets/js/hacker.min.js"></script>
</body>
</html>

View File

@@ -35,46 +35,110 @@
<link rel="stylesheet" href="/assets/css/resume.min.css"> <link rel="stylesheet" href="/assets/css/resume.min.css">
<link rel="stylesheet" href="/assets/css/Social-Icons.min.css"> <link rel="stylesheet" href="/assets/css/Social-Icons.min.css">
<link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" /> <link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" />
<script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script> <script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script><link rel="stylesheet" href="/assets/css/resume-print.css" media="print">
</head> </head>
<body style="width: 90%;margin-left: 5%;margin-right: 5%;font-family: 'Noto Sans', sans-serif;"> <body style="font-family: 'Noto Sans', sans-serif;"><div id="mobile-pdf-notice" style="display: none; background: #0d6efd; color: white; padding: 1rem; text-align: center; position: fixed; top: 0; left: 0; right: 0; z-index: 9999;">
<div class="d-none d-lg-inline d-xl-inline d-xxl-inline"> <strong>Mobile detected!</strong>
<div class="profile-container" style="margin-top: 5em;margin-bottom: 5em;"> <a href="/resume.pdf" style="color: white; text-decoration: underline;">View PDF version instead</a>
<div style="background-color: var(--bs-primary);height: 170px;width: 170px;margin-top: -10px;pointer-events: none;z-index: 1;position: absolute;border-radius: 50%;"></div><img class="profile foreground hideprint" src="/assets/img/nathanwoodburn.jpeg" alt="">
</div> </div>
<div class="title" style="text-align: right;background: var(--bs-primary);"> <script>
<h1>Nathan Woodburn</h1> if (window.innerWidth <= 768) {
<p><a href="https://github.com/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;display: inline;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github"> document.getElementById('mobile-pdf-notice').style.display = 'block';
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path> }
</svg></a>&nbsp;<a href="https://linkedin.com/in/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;display: inline;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-linkedin"> </script>
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854V1.146zm4.943 12.248V6.169H2.542v7.225h2.401m-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248-.822 0-1.359.54-1.359 1.248 0 .694.521 1.248 1.327 1.248h.016zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016a5.54 5.54 0 0 1 .016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225h2.4"></path> <div class="container-fluid h-100">
</svg></a>&nbsp;|&nbsp;<a href="mailto:contact@nathan.woodburn.au" style="color: rgb(255,255,255);text-decoration: none;display: inline;" target="_blank">contact@nathan.woodburn.au</a>&nbsp;|&nbsp;<a href="https://nathan.woodburn.au" style="color: rgb(255,255,255);text-decoration: none;display: inline;" target="_blank">https://nathan.woodburn.au</a></p> <div class="row h-100 resume-row">
</div> <div class="col-md-4 resume-column resume-column-left">
</div> <div class="row row-cols-1 row-fill">
<div class="d-lg-none d-xl-none d-xxl-none"> <div class="col">
<div class="profile-container" style="margin-top: 5em;margin-bottom: 10px;"><img class="profilesml foregroundsml" src="/assets/img/nathanwoodburn.jpeg" style="width: 170px;border: 10px solid var(--bs-primary) ;" alt=""></div> <div class="text-center"><img class="profile-side" src="/assets/img/nathanwoodburn.jpeg" alt=""></div>
<div style="text-align: center;margin-bottom: 25px;"> <h1 class="l-heading1">Contact</h1>
<h1 style="margin-bottom: 0px;">Nathan Woodburn</h1> <hr class="hr-l">
<div class="r-small"><a class="print_text" href="mailto:contact@nathan.woodburn.au" style="color: rgb(255,255,255);text-decoration: none;" target="_blank">contact@nathan.woodburn.au</a><span>&nbsp;|&nbsp;</span><a class="print_text" href="https://nathan.woodburn.au" style="color: rgb(255,255,255);text-decoration: none;" target="_blank">https://nathan.woodburn.au</a></div> <div class="r-small"><a class="print_text" href="tel:+61493129562" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-telephone-fill">
<path fill-rule="evenodd" d="M1.885.511a1.745 1.745 0 0 1 2.61.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"></path>
</svg>&nbsp;+61 493 129 562</a></div>
<div class="r-small"><a class="print_text" href="mailto:nathan@woodburn.au" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-envelope-fill">
<path d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558zM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586l-1.239-.757Zm3.436-.586L16 11.801V4.697l-5.803 3.546Z"></path>
</svg>&nbsp;nathan@woodburn.au</a></div>
<div class="r-small"><span class="print_text"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-pin-angle-fill">
<path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a5.927 5.927 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707-.195-.195.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a5.922 5.922 0 0 1 1.013.16l3.134-3.133a2.772 2.772 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146z"></path>
</svg>&nbsp;Canberra, ACT</span></div>
<div class="r-small"><a class="print_text" href="https://nathan.woodburn.au" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-link-45deg">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"></path>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"></path>
</svg>&nbsp;nathan.woodburn.au</a></div>
<div class="r-small"><a class="print_text" href="https://github.com/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github"> <div class="r-small"><a class="print_text" href="https://github.com/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path>
</svg>&nbsp;@nathanwoodburn</a><span>&nbsp;|&nbsp;</span><a class="print_text" href="https://linkedin.com/in/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-linkedin"> </svg>&nbsp;@nathanwoodburn</a></div>
<div class="r-small"><a class="print_text" href="https://linkedin.com/in/nathanwoodburn" style="color: rgb(255,255,255);text-decoration: none;" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-linkedin">
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854V1.146zm4.943 12.248V6.169H2.542v7.225h2.401m-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248-.822 0-1.359.54-1.359 1.248 0 .694.521 1.248 1.327 1.248h.016zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016a5.54 5.54 0 0 1 .016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225h2.4"></path> <path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854V1.146zm4.943 12.248V6.169H2.542v7.225h2.401m-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248-.822 0-1.359.54-1.359 1.248 0 .694.521 1.248 1.327 1.248h.016zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016a5.54 5.54 0 0 1 .016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225h2.4"></path>
</svg>&nbsp;@nathanwoodburn</a></div> </svg>&nbsp;@nathanwoodburn</a></div>
<div>
<div style="text-align: center;margin-bottom: 25px;"></div>
</div> </div>
</div> </div>
<div style="max-width: 2000px;margin: auto;"> <div class="col side-column">
<div style="margin-bottom: 50px;"> <h1 class="l-heading1">Education</h1>
<h1 class="r-heading3" style="font-size: 25px;">Summary</h1> <hr class="hr-l">
<p class="r-body">Linux and server administration student with experience managing servers, DNS, virtualization, and networking. Skilled in deploying and maintaining self-hosted services, troubleshooting complex system issues, and building resilient, automated infrastructures. Passionate about open-source tools and practical system design.</p>
</div>
<div class="row row-cols-1 row-cols-lg-2 row-cols-xl-2 row-cols-xxl-2">
<div class="col">
<div class="noprintbreak"> <div class="noprintbreak">
<h5 class="r-heading2">Bachelor of Computing</h5>
<h6 class="r-heading3">Australian National University<br>2022 - Present</h6>
</div>
<div class="noprintbreak">
<h5 class="r-heading2">Discovering Engineering</h5>
<h6 class="r-heading3">Australian National University<br>Years 11 - 12</h6>
<p class="l-body">Completed enrichment program in engineering disciplines, CAD modeling, and design thinking</p>
</div>
<div class="noprintbreak">
<h5 class="r-heading2">Home Educated</h5>
<h6 class="r-heading3">Self-Directed Learning</h6>
<p class="l-body">Developed passion for technology through independent exploration of programming, system administration, and server management.</p>
</div>
</div>
<div class="col side-column">
<h1 class="l-heading1">Skills</h1>
<hr class="hr-l">
<div class="noprintbreak">
<ul class="r-body">
<li>Python 3</li>
<li>Git</li>
<li>Docker Containerization</li>
<li>DNS</li>
<li>Linux administration</li>
<li>Technical troubleshooting</li>
</ul>
</div>
</div>
</div>
</div>
<div class="col resume-column resume-column-right">
<div style="margin: 3em;">
<h1 class="title" style="margin-bottom: 0px;">Nathan Woodburn</h1>
<h1 class="subtitle r-heading3" style="font-size: 25px;color: var(--bs-gray);">{% if support %}Technical Support Specialist{% else %}Linux Systems Administrator{% endif %}</h1>
<hr class="title-hr">
</div>
<div class="l-summary">
<h1 class="r-heading1">Summary</h1>
<hr class="hr-l-primary">
<p class="r-body">{% if support %}Technical Support Specialist with expertise in Linux, DNS, and network troubleshooting. Experienced in resolving critical domain and network issues, supporting end-users, and collaborating with engineering teams to ensure stable and secure systems. Skilled in Python automation to streamline repetitive tasks and improve operational efficiency.{% else %}System Administrator specializing in Linux, Docker, and server deployments. Experienced in managing Proxmox, networks, and CI/CD pipelines. Implementing Python automations to optimize system operations. Ability to deploy and maintain server environments, self-hosted services, and web applications while ensuring reliability, scalability, and security.{% endif %}</p>
</div>
<div class="row g-0 row-cols-1">
<div class="col">
<h1 class="r-heading1">Experience</h1> <h1 class="r-heading1">Experience</h1>
<h4 class="r-heading2">Technical Support Specialist</h4> <hr class="hr-l-primary">
<h6 class="r-heading3">Namebase - Remote |&nbsp;Oct 2022 - JUN 2025</h6> <div class="noprintbreak">
<h4 class="l-heading2 float-right">Dec 2025 - Present</h4>
<h4 class="l-heading2">Web Hosting System Administrator</h4>
<h6 class="l-heading3">CSIRO - Canberra</h6>
<ul class="r-body">
<li>Configure and manage web services</li>
</ul>
</div>
<div class="noprintbreak">
<h4 class="l-heading2 float-right">Oct 2022 - Jun 2025</h4>
<h4 class="l-heading2">Technical Support Specialist</h4>
<h6 class="l-heading3">Namebase - Remote</h6>
<ul class="r-body"> <ul class="r-body">
<li>Provided technical support for users, focusing on domain setup, configuration, and troubleshooting.</li> <li>Provided technical support for users, focusing on domain setup, configuration, and troubleshooting.</li>
<li>Worked with engineering teams to report bugs and suggest product improvements.</li> <li>Worked with engineering teams to report bugs and suggest product improvements.</li>
@@ -82,186 +146,46 @@
<li>Gained hands-on experience with recursive and authoritative DNS, DNSSEC, and decentralized naming.</li> <li>Gained hands-on experience with recursive and authoritative DNS, DNSSEC, and decentralized naming.</li>
<li>Engaged with the community through social platforms and represented Namebase at conferences.</li> <li>Engaged with the community through social platforms and represented Namebase at conferences.</li>
</ul> </ul>
<hr>
</div> </div>
<div class="noprintbreak"> <div class="noprintbreak">
<h4 class="r-heading2">Small Business Owner</h4> <h4 class="l-heading2 float-right">Feb 2020 - Dec 2023</h4>
<h6 class="r-heading3">Nathan 3D Printing Service |&nbsp;Feb 2020 - Dec 2023</h6> <h4 class="l-heading2">Small Business Owner</h4>
<h6 class="l-heading3">Nathan 3D Printing Service</h6>
<ul class="r-body"> <ul class="r-body">
<li>Operated a custom 3D printing and CAD design business independently.</li> <li>Operated a custom 3D printing and CAD design business independently.</li>
<li>Handled client communication, design iteration, and order fulfillment.</li> <li>Handled client communication, design iteration, and order fulfillment.</li>
<li>Built end-to-end project management and technical design skills.</li> <li>Built end-to-end project management and technical design skills.</li>
</ul> </ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Audio Production&nbsp;Volunteer</h4>
<h6 class="r-heading3">1WAY FM |&nbsp;Feb 2021 - Dec 2021</h6>
<ul class="r-body">
<li>Recorded, edited, and produced audio content for community radio broadcasts.</li>
<li>Supported the production team in day-to-day technical operations.</li>
<li>Gained practical skills in audio engineering and collaborative media work.</li>
</ul>
<hr>
</div> </div>
</div> </div>
<div class="col edu-main">
<div class="noprintbreak">
<h1 class="r-heading1">Education</h1>
<h4 class="r-heading2">Bachelor of Computing</h4>
<h6 class="r-heading3">Australian National University |&nbsp;2022 - Present</h6>
<ul class="r-body">
<li>Currently pursuing a Bachelor of Computing with a specialization in cybersecurity.</li>
<li>Gaining hands-on experience in network security, system design, and secure software development.</li>
<li>Building a strong foundation in computer science principles, programming, and system architecture.</li>
<li>Collaborating on group projects and labs to apply theoretical knowledge to real-world challenges.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Discovering Engineering</h4>
<h6 class="r-heading3">Australian National University |&nbsp;YearS 11 &amp; 12</h6>
<ul class="r-body">
<li>Completed an enrichment program introducing core engineering disciplines and technical concepts.</li>
<li>Explored CAD modeling, design thinking, and practical problem-solving through workshops and case studies.</li>
<li>Gained early exposure to engineering tools and technical communication, laying the groundwork for later technical studies.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Home Educated</h4>
<h6 class="r-heading3">Self-Directed Learning</h6>
<ul class="r-body">
<li>Cultivated time management, self-discipline, and critical thinking skills crucial for success in tech.</li>
<li>Developed a strong passion for technology, programming, and system administration through independent exploration.</li>
<li>Built custom applications, managed servers, and solved technical challenges in a flexible learning environment.</li>
</ul>
<hr>
</div>
</div>
</div>
<div class="spacer"></div>
<div class="col"> <div class="col">
<h1 class="r-heading1">Projects</h1> <h1 class="r-heading1">Projects</h1>
<hr class="hr-l-primary">
<div class="noprintbreak"> <div class="noprintbreak">
<h4 class="r-heading2">Server Lab</h4> <h4 class="l-heading2">Server Lab</h4>
<h6 class="r-heading3">Proxmox, Networking, Linux, DNS</h6> <h6 class="l-heading3">Proxmox, Networking, Linux, DNS</h6>
<ul class="r-body"> <ul class="r-body">
<li>Maintain a personal physical server running Proxmox hypervisor.</li> <li>Maintain a personal physical server running Proxmox hypervisor.</li>
<li>Host multiple virtual machines across three VLANs with isolated firewalls for enhanced security.</li> <li>Host multiple virtual machines across three VLANs with isolated firewalls for enhanced security.</li>
<li>Provide DNS and recursive resolver hosting services for external users.</li> <li>Provide DNS and recursive resolver hosting services for external users.</li>
<li>Host a suite of self-hosted services such as Gitea, Authentik, Vaultwarden and Nextcloud.</li> <li>Host a suite of self-hosted services such as Gitea, Authentik, Vaultwarden and Nextcloud.</li>
</ul> </ul>
<hr>
</div> </div>
<div class="noprintbreak"> <div class="noprintbreak">
<h4 class="r-heading2">HNSDoH</h4> <h4 class="l-heading2">Personal Website</h4>
<h6 class="r-heading3">DNS, Handshake, DoH, Distributed Systems, Linux</h6> <h6 class="l-heading3">Python 3, Flask, Docker, CI/CD</h6>
<ul class="r-body"> <ul class="r-body">
<li>Manage a distributed Handshake DoH resolver network spanning six independent nodes.</li> <li>Designed modular web application architecture with Flask blueprints and reusable templates.</li>
<li>Administer four nodes and collaborate with two external operators on updates, patches, and troubleshooting.</li> <li>Managed containerized deployment using Docker on a dedicated server, ensuring consistency and scalability.</li>
<li>Ensure uptime and resiliency across geographically distributed infrastructure.</li> <li>Implemented CI/CD pipelines for automated testing, building, and deployment from Git.</li>
<li>Integrated dynamic content and interactive features while maintaining secure and optimized server operations.</li>
</ul> </ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">FireWallet</h4>
<h6 class="r-heading3">Python, Handshake, Plugin Architecture</h6>
<ul class="r-body">
<li>Developed a modular Python-based Handshake wallet with plugin support for extensibility.</li>
<li>Presented at HandyCon 2024 and 2025, showcasing usability improvements and HNS site resolution.</li>
</ul>
<hr>
</div> </div>
</div> </div>
<div class="spacer"></div>
<div>
<div class="noprintbreak">
<h1 class="r-heading1">Skills</h1>
<h4 class="r-heading2">Programming &amp; Development</h4>
<ul class="r-body">
<li><strong>Python 3</strong>: Proficient in building web services and automation tools; experienced with libraries such as Flask, requests, and asyncio.</li>
<li><strong>C &amp; Java</strong>: Applied in university coursework and labs for systems programming, algorithms, and object-oriented design.</li>
<li><strong>C#</strong>: Experienced in building Windows applications, including debugging and testing since 2016.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Networking &amp; Security</h4>
<ul class="r-body">
<li><strong>DNS &amp; DNSSEC</strong>: Skilled in managing DNS zones, records, and DNSSEC; experienced with both authoritative and recursive resolvers.</li>
<li><strong>Linux System Administration</strong>: Manage cloud and physical servers, using the command line for scripting, security, and package management.</li>
<li><strong>Server Infrastructure</strong>: Operate a dedicated server running Proxmox; manage virtual machines across VLANs with separate firewalls to enhance isolation and security.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Technical Support &amp; Communication</h4>
<ul class="r-body">
<li><strong>Technical Support</strong>: Deliver front-line technical assistance, troubleshoot software/platform issues, and communicate clearly with users.</li>
<li><strong>Community Engagement</strong>: Active contributor and presenter within the Handshake and blockchain communities.</li>
<li><strong>Tools</strong>: Git, Docker, NGINX, SSH, Bash scripting.</li>
</ul>
<hr>
</div> </div>
</div> </div>
<div class="spacer"></div>
<div>
<h1 class="r-heading1">Conferences</h1>
<div class="noprintbreak">
<h4 class="r-heading2">Presenter HandyCon 2025</h4>
<h6 class="r-heading3">Online | March 2025</h6>
<ul class="r-body">
<li><strong>Firewallet Updates &amp; How to Resolve HNS Sites</strong> Presented new features and usability improvements in FireWallet, including user-friendly Handshake resolution methods.</li>
<li><strong>Building the Future of Handshake: Advancing Wallets &amp; Ecosystem Development</strong> (co-presented with Rithvik Vibhu) Discussed strategies for wallet development, improving developer tooling, and enhancing the decentralized web experience on Handshake.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Judge &amp; Speaker Onchain Names &amp; Identity Hackathon</h4>
<h6 class="r-heading3">Vietnam | April 2024</h6>
<ul class="r-body">
<li>Invited judge for blockchain-focused hackathon entries using Handshake and decentralized identity tools.</li>
<li>Delivered a talk comparing Handshake DNS with traditional DNS systems, highlighting benefits of decentralized roots for security and censorship resistance.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Presenter HandyCon 2024</h4>
<h6 class="r-heading3">Online | March 2024</h6>
<ul class="r-body">
<li><strong>FireWallet</strong> Showcased a modular Handshake wallet written in Python, designed with plugin support to enable extensibility and developer customization.</li>
</ul>
<hr>
</div>
<div class="noprintbreak">
<h4 class="r-heading2">Presenter HandyCon 2023</h4>
<h6 class="r-heading3">Online | March 2023</h6>
<ul class="r-body">
<li>Presented a technical walkthrough on launching websites with Handshake domains.</li>
<li>Covered HTTPS setup using DANE to eliminate reliance on traditional certificate authorities.</li>
</ul>
<hr>
</div> </div>
</div> </div>
<div class="spacer"></div>
</div>
<footer class="text-center bg-dark d-print-none" style="width: 99vw;margin-left: -5vw;padding: 0px;">
<div class="container text-white py-4 py-lg-5" style="width: auto;max-width: 100%;">
<ul class="list-inline">
<li class="list-inline-item me-4"><a href="https://www.facebook.com/nathanjwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-facebook text-light">
<path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951"></path>
</svg></a></li>
<li class="list-inline-item me-4"><a href="https://twitter.com/woodburn_nathan" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-twitter text-light">
<path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15"></path>
</svg></a></li>
<li class="list-inline-item me-4"><a href="https://github.com/nathanwoodburn" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-github text-light">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"></path>
</svg></a></li>
</ul>
<p class="text-muted mb-0" style="color: rgb(255,255,255) !important;">Copyright © Nathan.Woodburn/ 2025</p>
</div>
</footer>
<script src="/assets/bootstrap/js/bootstrap.min.js"></script> <script src="/assets/bootstrap/js/bootstrap.min.js"></script>
<script src="/assets/js/script.min.js"></script> <script src="/assets/js/script.min.js"></script>
<script src="/assets/js/grayscale.min.js"></script> <script src="/assets/js/grayscale.min.js"></script>

View File

@@ -72,6 +72,9 @@
<url> <url>
<loc>https://nathan.woodburn.au/now/25_10_23</loc> <loc>https://nathan.woodburn.au/now/25_10_23</loc>
</url> </url>
<url>
<loc>https://nathan.woodburn.au/now/25_11_20</loc>
</url>
<url> <url>
<loc>https://nathan.woodburn.au/now/old</loc> <loc>https://nathan.woodburn.au/now/old</loc>
</url> </url>

View File

@@ -1,472 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal | Nathan.Woodburn/</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
font-family: 'Courier New', monospace;
color: #0f0;
overflow: hidden;
}
#terminal {
width: 100vw;
height: 100vh;
padding: 20px;
overflow-y: auto;
}
.line { margin-bottom: 5px; }
.prompt { color: #0f0; white-space: pre; }
.prompt-line { color: #0f0; }
.output { color: #fff; white-space: pre-wrap; }
.error { color: #f00; }
#input-line {
display: block;
}
.input-row {
display: flex;
}
#command-input {
background: transparent;
border: none;
color: #0f0;
font-family: 'Courier New', monospace;
font-size: 16px;
outline: none;
flex: 1;
caret-color: #0f0;
}
#nano-editor {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 1000;
flex-direction: column;
}
#nano-editor.active {
display: flex;
}
#nano-header {
padding: 10px 20px;
border-bottom: 1px solid #0f0;
color: #0f0;
}
#nano-textarea {
flex: 1;
background: #000;
color: #fff;
border: none;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 16px;
resize: none;
outline: none;
}
#nano-footer {
padding: 10px 20px;
border-top: 1px solid #0f0;
color: #0f0;
font-size: 14px;
}
#nano-save-prompt {
display: none;
padding: 10px 20px;
background: #111;
border-top: 1px solid #0f0;
color: #fff;
}
#nano-save-prompt.active {
display: block;
}
</style>
</head>
<body>
<div id="terminal">
<div class="line output">Nathan.Woodburn/</div>
<div class="line output">Type 'help' for available commands</div>
<div class="line output"></div>
<div id="input-line">
<div class="prompt-line">┌──({{user}}@NW)-[~]</div>
<div class="input-row">
<span class="prompt-line">└─$&nbsp;</span>
<input type="text" id="command-input" autofocus autocomplete="off" spellcheck="false" autocapitalize="none">
</div>
</div>
</div>
<!-- Nano Editor -->
<div id="nano-editor">
<div id="nano-header">nano - <span id="nano-filename"></span></div>
<textarea id="nano-textarea"></textarea>
<div id="nano-save-prompt">Save modified buffer? (Y/N)</div>
<div id="nano-footer">^X Exit ^O Save (Ctrl+X to exit, Ctrl+O to save)</div>
</div>
<script>
const terminal = document.getElementById('terminal');
const input = document.getElementById('command-input');
const inputLine = document.getElementById('input-line');
const nanoEditor = document.getElementById('nano-editor');
const nanoTextarea = document.getElementById('nano-textarea');
const nanoFilename = document.getElementById('nano-filename');
let commandHistory = [];
let historyIndex = -1;
let currentPwd = '~';
let nanoCurrentFile = '';
let nanoOriginalContent = '';
let nanoSavePromptActive = false;
let tabCompletionIndex = 0;
let tabCompletionMatches = [];
let lastTabInput = '';
// Function to update the prompt with current pwd
async function updatePrompt() {
try {
const response = await fetch('/terminal/pwd');
const data = await response.json();
if (data.output) {
currentPwd = data.output;
inputLine.querySelector('.prompt-line:first-child').textContent = `┌──({{user}}@NW)-[${currentPwd}]`;
}
} catch (err) {
console.error('Failed to fetch pwd:', err);
}
}
// Function to open nano editor
async function openNano(filename) {
nanoCurrentFile = filename;
nanoFilename.textContent = filename;
nanoSavePromptActive = false;
document.getElementById('nano-save-prompt').classList.remove('active');
// Try to fetch existing file content
try {
const parts = ['cat'].concat(filename.split(' '));
const response = await fetch('/terminal/execute/cat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ args: filename })
});
const data = await response.json();
if (data.output && !data.output.startsWith('cat:')) {
nanoTextarea.value = data.output;
nanoOriginalContent = data.output;
} else {
nanoTextarea.value = '';
nanoOriginalContent = '';
}
} catch (err) {
nanoTextarea.value = '';
nanoOriginalContent = '';
}
nanoEditor.classList.add('active');
nanoTextarea.focus();
}
// Function to close nano editor
async function closeNano(shouldSave = false) {
if (shouldSave) {
await saveNano();
}
nanoEditor.classList.remove('active');
nanoSavePromptActive = false;
document.getElementById('nano-save-prompt').classList.remove('active');
terminal.appendChild(inputLine);
inputLine.style.display = 'block';
input.disabled = false;
input.focus();
terminal.scrollTop = terminal.scrollHeight;
}
// Function to save nano content
async function saveNano() {
const content = nanoTextarea.value;
try {
const response = await fetch('/terminal/execute/nano', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ args: `${nanoCurrentFile} CONTENT ${content}` })
});
const data = await response.json();
nanoOriginalContent = content; // Update original content after save
// If output contains error, display it
if (data.output && data.output.startsWith('nano:')) {
const errorLine = document.createElement('div');
errorLine.className = 'line error';
errorLine.textContent = data.output;
terminal.appendChild(errorLine);
}
// Don't display output - nano saves silently
} catch (err) {
const errorLine = document.createElement('div');
errorLine.className = 'line error';
errorLine.textContent = 'Error saving file: ' + err.message;
terminal.appendChild(errorLine);
}
}
// Check if content has changed
function hasUnsavedChanges() {
return nanoTextarea.value !== nanoOriginalContent;
}
// Nano editor keyboard shortcuts
nanoTextarea.addEventListener('keydown', async (e) => {
if (e.ctrlKey && e.key === 'x') {
e.preventDefault();
if (nanoSavePromptActive) {
return; // Ignore if prompt is already active
}
if (hasUnsavedChanges()) {
// Show save prompt
nanoSavePromptActive = true;
document.getElementById('nano-save-prompt').classList.add('active');
document.getElementById('nano-footer').textContent = 'Press Y to save, N to discard, Ctrl+C to cancel';
} else {
await closeNano(false);
}
} else if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
await saveNano();
} else if (e.ctrlKey && e.key === 'c' && nanoSavePromptActive) {
e.preventDefault();
// Cancel save prompt
nanoSavePromptActive = false;
document.getElementById('nano-save-prompt').classList.remove('active');
document.getElementById('nano-footer').textContent = '^X Exit ^O Save (Ctrl+X to exit, Ctrl+O to save)';
} else if (nanoSavePromptActive && (e.key === 'y' || e.key === 'Y')) {
e.preventDefault();
await closeNano(true);
} else if (nanoSavePromptActive && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
await closeNano(false);
}
});
// Function to get available commands
function getAvailableCommands() {
return ['help', 'about', 'clear', 'echo', 'whoami', 'ls', 'pwd', 'date', 'cd', 'cat', 'rm', 'tree', 'touch', 'nano', 'reset', 'exit'];
}
// Function to get files/directories for tab completion
async function getFilesForCompletion(path = '') {
try {
const response = await fetch('/terminal/execute/ls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ args: path ? path : '' })
});
const data = await response.json();
if (data.output && !data.output.startsWith('ls:')) {
return data.output.split(' ').filter(f => f.length > 0);
}
} catch (err) {
console.error('Failed to get files:', err);
}
return [];
}
// Function to handle tab completion
async function handleTabCompletion(currentInput) {
const parts = currentInput.split(' ');
const isFirstWord = parts.length === 1;
if (isFirstWord) {
// Complete command names
const commands = getAvailableCommands();
const matches = commands.filter(cmd => cmd.startsWith(parts[0]));
if (matches.length === 1) {
return matches[0] + ' ';
} else if (matches.length > 1) {
// Cycle through matches
if (lastTabInput === currentInput) {
tabCompletionIndex = (tabCompletionIndex + 1) % matches.length;
} else {
tabCompletionIndex = 0;
tabCompletionMatches = matches;
}
lastTabInput = currentInput;
return matches[tabCompletionIndex] + ' ';
}
} else {
// Complete file/directory names
const lastPart = parts[parts.length - 1];
const files = await getFilesForCompletion();
const matches = files.filter(f => f.startsWith(lastPart));
if (matches.length === 1) {
parts[parts.length - 1] = matches[0];
return parts.join(' ') + ' ';
} else if (matches.length > 1) {
// Cycle through matches
if (lastTabInput === currentInput) {
tabCompletionIndex = (tabCompletionIndex + 1) % matches.length;
} else {
tabCompletionIndex = 0;
tabCompletionMatches = matches;
}
lastTabInput = currentInput;
parts[parts.length - 1] = matches[tabCompletionIndex];
return parts.join(' ');
}
}
return currentInput;
}
// Update prompt on load
updatePrompt();
input.addEventListener('keydown', async (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const currentInput = input.value;
const completed = await handleTabCompletion(currentInput);
input.value = completed;
return;
}
// Reset tab completion on any other key
if (e.key !== 'Tab') {
tabCompletionIndex = 0;
tabCompletionMatches = [];
lastTabInput = '';
}
if (e.key === 'Enter') {
const command = input.value.trim();
// Disable input and hide prompt during execution
input.disabled = true;
inputLine.style.display = 'none';
// Display command
const cmdLine = document.createElement('div');
cmdLine.className = 'line';
cmdLine.innerHTML = `<span class="prompt">┌──({{user}}@NW)-[${currentPwd}]\n└─$ </span>${command}`;
terminal.appendChild(cmdLine);
// Add to history
if (command) {
commandHistory.push(command);
historyIndex = commandHistory.length;
}
input.value = '';
// Handle clear command locally
if (command === 'clear') {
terminal.innerHTML = '';
terminal.appendChild(inputLine);
inputLine.style.display = 'block';
input.disabled = false;
input.focus();
return;
}
if (command === 'exit') {
// Redirect to /
window.location.href = '/';
return;
}
// Handle nano command specially
const parts = command.split(' ');
const baseCommand = parts[0];
if (baseCommand === 'nano') {
if (parts.length < 2) {
const errorLine = document.createElement('div');
errorLine.className = 'line output';
errorLine.textContent = 'Usage: nano [filename]';
terminal.appendChild(errorLine);
terminal.appendChild(inputLine);
inputLine.style.display = 'block';
input.disabled = false;
input.focus();
return;
}
await openNano(parts.slice(1).join(' '));
return;
}
// Execute command
if (command) {
try {
// Split command by space
const args = parts.slice(1).join(' ');
// POST request to /terminal/execute/command
const response = await fetch(`/terminal/execute/${encodeURIComponent(baseCommand)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ args: args })
});
const data = await response.json();
if (data.output) {
const outputLine = document.createElement('div');
outputLine.className = 'line output';
outputLine.textContent = data.output;
terminal.appendChild(outputLine);
}
// Update prompt after cd command
if (baseCommand === 'cd' || baseCommand === 'reset') {
await updatePrompt();
}
} catch (err) {
const errorLine = document.createElement('div');
errorLine.className = 'line error';
errorLine.textContent = 'Error: ' + err.message;
terminal.appendChild(errorLine);
}
}
// Show prompt and re-enable input after command completes
terminal.appendChild(inputLine);
inputLine.style.display = 'block';
input.disabled = false;
input.focus();
terminal.scrollTop = terminal.scrollHeight;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = commandHistory[historyIndex];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
historyIndex++;
input.value = commandHistory[historyIndex];
} else {
historyIndex = commandHistory.length;
input.value = '';
}
}
});
// Keep input focused
document.addEventListener('click', () => input.focus());
input.focus();
</script>
</body>
</html>

View File

@@ -11,7 +11,7 @@ Here are some of the tools I use regularly — most of them are open source!
{{tool.name}} {{tool.name}}
{{tool.description}} {{tool.description}}
Website: {{tool.url}} Website: {{tool.url}}
{% if tool.demo_url %}Demo: {{tool.demo_url}}{% endif %} {% if tool.demo %}Demo: {{tool.demo}}{% endif %}
{% endfor %} {% endfor %}
─────────────────────────────────────────────── ───────────────────────────────────────────────

View File

@@ -35,6 +35,7 @@
<link rel="stylesheet" href="/assets/css/brand-reveal.min.css"> <link rel="stylesheet" href="/assets/css/brand-reveal.min.css">
<link rel="stylesheet" href="/assets/css/profile.min.css"> <link rel="stylesheet" href="/assets/css/profile.min.css">
<link rel="stylesheet" href="/assets/css/Social-Icons.min.css"> <link rel="stylesheet" href="/assets/css/Social-Icons.min.css">
<link rel="stylesheet" href="/assets/css/tools.min.css">
<link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" /> <link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" />
<script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script> <script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script>
</head> </head>
@@ -76,11 +77,15 @@
<div class="row"> <div class="row">
{% for tool in tools_in_type %} {% for tool in tools_in_type %}
<div class="col-md-6 col-lg-4 mb-4"> <div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 shadow-sm transition-all" style="transition: transform 0.2s, box-shadow 0.2s;" onmouseover="this.style.transform='translateY(-5px)'; this.style.boxShadow='0 0.5rem 1rem rgba(0,0,0,0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='';"> <div class="card h-100 shadow-sm transition-all" style="transition: transform 0.2s, box-shadow 0.2s;">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<h4 class="card-title">{{tool.name}}</h4> <h4 class="card-title">{{tool.name}}</h4>
<p class="card-text">{{ tool.description }}</p> <p class="card-text">{{ tool.description }}</p>
<div class="btn-group gap-3 mt-auto" role="group">{% if tool.demo %}<button class="btn btn-primary" type="button" data-bs-target="#modal-{{tool.name}}" data-bs-toggle="modal" style="transition: transform 0.2s, background-color 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">View Demo</button>{% endif %}<a class="btn btn-primary" role="button" href="{{tool.url}}" target="_blank" style="transition: transform 0.2s, background-color 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">{{tool.name}} Website</a></div> <div class="btn-group gap-3 mt-auto" role="group">{% if tool.demo %}<button class="btn btn-primary"
type="button" data-bs-target="#modal-{{tool.name}}" data-bs-toggle="modal"
style="transition: transform 0.2s, background-color 0.2s;">View Demo</button>{% endif %}<a
class="btn btn-primary" role="button" href="{{tool.url}}" target="_blank"
style="transition: transform 0.2s, background-color 0.2s;">{{tool.name}} Website</a></div>
</div> </div>
</div> </div>
</div> </div>
@@ -89,16 +94,19 @@
<!-- Modals for this type --> <!-- Modals for this type -->
{% for tool in tools_in_type %} {% for tool in tools_in_type %}
{% if tool.demo %} {% if tool.demo %}
<div id="modal-{{tool.name}}" class="modal fade" role="dialog" tabindex="-1" style="z-index: 1055;"> <div id="modal-{{tool.name}}" class="modal fade" role="dialog" tabindex="-1" style="z-index: 1055;"
data-demo-url="{{ tool.demo | e }}">
<div class="modal-dialog modal-xl" role="document"> <div class="modal-dialog modal-xl" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{tool.name}}</h4><button class="btn-close" type="button" aria-label="Close" data-bs-dismiss="modal"></button> <h4 class="modal-title">{{tool.name}}</h4><button class="btn-close" type="button" aria-label="Close"
data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body">
{{ tool.demo | safe }} <div class="modal-body" data-demo-loaded="false"></div>
</div>
<div class="modal-footer"><button class="btn btn-light" type="button" data-bs-dismiss="modal">Close</button>
</div> </div>
<div class="modal-footer"><button class="btn btn-light" type="button" data-bs-dismiss="modal">Close</button></div>
</div> </div>
</div> </div>
</div> </div>
@@ -132,6 +140,64 @@ document.addEventListener('DOMContentLoaded', function() {
}, 0); }, 0);
} }
} }
// Load demo in modal
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('show.bs.modal', () => {
const body = modal.querySelector('.modal-body');
if (body.dataset.demoLoaded === 'false') {
const demoUrl = modal.dataset.demoUrl;
const iframeId = 'iframe-' + modal.id;
// Add a div on top of all content to show loading message
const loadingDiv = document.createElement('div');
loadingDiv.style.position = 'absolute';
loadingDiv.style.top = '0';
loadingDiv.style.left = '0';
loadingDiv.style.width = '100%';
loadingDiv.style.height = '100%';
loadingDiv.style.backgroundColor = 'rgb(0, 0, 0)';
loadingDiv.style.display = 'flex';
loadingDiv.style.justifyContent = 'center';
loadingDiv.style.alignItems = 'center';
loadingDiv.style.zIndex = '10';
const loadingMsg = document.createElement('p');
loadingMsg.className = 'text-center';
loadingMsg.textContent = 'Loading demo...';
loadingDiv.appendChild(loadingMsg);
body.style.position = 'relative';
body.appendChild(loadingDiv);
// Create iframe
const iframe = document.createElement('iframe');
iframe.src = demoUrl + '/iframe';
iframe.id = iframeId;
iframe.style.width = '100%';
iframe.style.height = '400px'; // temporary height
iframe.style.border = '0';
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('allowfullscreen', 'true');
body.appendChild(iframe);
body.dataset.demoLoaded = 'true';
// Listen for bodySize message from asciinema iframe
const origin = new URL(demoUrl).origin;
function onMessage(event) {
if (event.origin !== origin || event.source !== iframe.contentWindow) return;
if (event.data.type === 'bodySize' && event.data.payload.height) {
iframe.style.height = event.data.payload.height + 'px';
// Remove loading message
body.removeChild(loadingDiv);
// Optional: limit modal max height
modal.querySelector('.modal-dialog').style.maxHeight = '90vh';
}
}
window.addEventListener('message', onMessage, false);
}
});
});
}); });
</script></div> </script></div>
</section> </section>

View File

@@ -5,11 +5,16 @@ HTTP 200
GET http://127.0.0.1:5000/api/v1/ip GET http://127.0.0.1:5000/api/v1/ip
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.ip" == "127.0.0.1" jsonpath "$.ip" matches "^(127|172).(0|17).0.1$"
GET http://127.0.0.1:5000/api/v1/time GET http://127.0.0.1:5000/api/v1/time
HTTP 200 HTTP 200
GET http://127.0.0.1:5000/api/v1/timezone GET http://127.0.0.1:5000/api/v1/timezone
HTTP 200 HTTP 200
[Asserts]
jsonpath "$.timezone" >= 10
jsonpath "$.timezone" <= 12
GET http://127.0.0.1:5000/api/v1/message GET http://127.0.0.1:5000/api/v1/message
HTTP 200 HTTP 200
GET http://127.0.0.1:5000/api/v1/project GET http://127.0.0.1:5000/api/v1/project
@@ -18,3 +23,6 @@ GET http://127.0.0.1:5000/api/v1/tools
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.tools" count > 5 jsonpath "$.tools" count > 5
GET http://127.0.0.1:5000/api/v1/playing
HTTP 200

View File

@@ -1,6 +1,6 @@
from flask import Request, render_template, jsonify, make_response from flask import Request, render_template, jsonify, make_response
import os import os
from functools import lru_cache as cache from functools import lru_cache
import datetime import datetime
from typing import Optional, Dict, Union, Tuple from typing import Optional, Dict, Union, Tuple
import re import re
@@ -27,6 +27,15 @@ CRAWLERS = [
"Twitterbot" "Twitterbot"
] ]
CLI_AGENTS = [
"curl",
"hurl",
"xh",
"Posting",
"HTTPie",
"nushell"
]
def getClientIP(request: Request) -> str: def getClientIP(request: Request) -> str:
""" """
@@ -47,7 +56,7 @@ def getClientIP(request: Request) -> str:
ip = "unknown" ip = "unknown"
return ip return ip
@cache @lru_cache(maxsize=1)
def getGitCommit() -> str: def getGitCommit() -> str:
""" """
Get the current git commit hash. Get the current git commit hash.
@@ -75,7 +84,7 @@ def getGitCommit() -> str:
return "failed to get version" return "failed to get version"
def isCurl(request: Request) -> bool: def isCLI(request: Request) -> bool:
""" """
Check if the request is from curl or hurl. Check if the request is from curl or hurl.
@@ -87,7 +96,7 @@ def isCurl(request: Request) -> bool:
""" """
if request.headers and request.headers.get("User-Agent"): if request.headers and request.headers.get("User-Agent"):
user_agent = request.headers.get("User-Agent", "") user_agent = request.headers.get("User-Agent", "")
return "curl" in user_agent or "hurl" in user_agent return any(agent in user_agent for agent in CLI_AGENTS)
return False return False
@@ -106,7 +115,7 @@ def isCrawler(request: Request) -> bool:
return any(crawler in user_agent for crawler in CRAWLERS) return any(crawler in user_agent for crawler in CRAWLERS)
return False return False
@cache @lru_cache(maxsize=128)
def isDev(host: str) -> bool: def isDev(host: str) -> bool:
""" """
Check if the host indicates a development environment. Check if the host indicates a development environment.
@@ -126,7 +135,7 @@ def isDev(host: str) -> bool:
return True return True
return False return False
@cache @lru_cache(maxsize=128)
def getHandshakeScript(host: str) -> str: def getHandshakeScript(host: str) -> str:
""" """
Get the handshake script HTML snippet. Get the handshake script HTML snippet.
@@ -141,7 +150,7 @@ def getHandshakeScript(host: str) -> str:
return "" return ""
return '<script src="https://nathan.woodburn/handshake.js" domain="nathan.woodburn" async></script><script src="https://nathan.woodburn/https.js" async></script>' return '<script src="https://nathan.woodburn/handshake.js" domain="nathan.woodburn" async></script><script src="https://nathan.woodburn/https.js" async></script>'
@cache @lru_cache(maxsize=64)
def getAddress(coin: str) -> str: def getAddress(coin: str) -> str:
""" """
Get the wallet address for a cryptocurrency. Get the wallet address for a cryptocurrency.
@@ -160,7 +169,7 @@ def getAddress(coin: str) -> str:
return address return address
@cache @lru_cache(maxsize=256)
def getFilePath(name: str, path: str) -> Optional[str]: def getFilePath(name: str, path: str) -> Optional[str]:
""" """
Find a file in a directory tree. Find a file in a directory tree.
@@ -202,7 +211,6 @@ def json_response(request: Request, message: Union[str, Dict] = "404 Not Found",
"ip": getClientIP(request), "ip": getClientIP(request),
}), code }), code
def error_response( def error_response(
request: Request, request: Request,
message: str = "404 Not Found", message: str = "404 Not Found",
@@ -221,7 +229,7 @@ def error_response(
Returns: Returns:
Union[Tuple[Dict, int], object]: The JSON or HTML response Union[Tuple[Dict, int], object]: The JSON or HTML response
""" """
if force_json or isCurl(request): if force_json or isCLI(request):
return json_response(request, message, code) return json_response(request, message, code)
# Check if <error code>.html exists in templates # Check if <error code>.html exists in templates

876
uv.lock generated Normal file
View File

@@ -0,0 +1,876 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[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, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "ansi2html"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/d5/e3546dcd5e4a9566f4ed8708df5853e83ca627461a5b048a861c6f8e7a26/ansi2html-1.9.2.tar.gz", hash = "sha256:3453bf87535d37b827b05245faaa756dbab4ec3d69925e352b6319c3c955c0a5", size = 44300, upload-time = "2024-06-22T17:33:23.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/71/aee71b836e9ee2741d5694b80d74bfc7c8cd5dbdf7a9f3035fcf80d792b1/ansi2html-1.9.2-py3-none-any.whl", hash = "sha256:dccb75aa95fb018e5d299be2b45f802952377abfdce0504c17a6ee6ef0a420c5", size = 17614, upload-time = "2024-06-22T17:33:21.852Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "cachetools"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "cloudflare"
version = "4.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/48/e481c0a9b9010a5c41b5ca78ff9fbe00dc8a9a4d39da5af610a4ec49c7f7/cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258", size = 1933187, upload-time = "2025-06-16T21:43:18.716Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/8f/c6c543565efd3144da4304efa5917aac06b6416a8663a6defe0e9b2b7569/cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6", size = 4406465, upload-time = "2025-06-16T21:43:17.3Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "construct"
version = "2.10.68"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/a4a032e94bcfdff481f2e6fecd472794d9da09f474a2185ed33b2c7cad64/construct-2.10.68.tar.gz", hash = "sha256:7b2a3fd8e5f597a5aa1d614c3bd516fa065db01704c72a1efaaeec6ef23d8b45", size = 57856, upload-time = "2022-02-21T23:09:15.1Z" }
[[package]]
name = "construct-typing"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/13/c609e60a687252813aa4b69f989f42754ccd5e217717216fc852eefedfd7/construct-typing-0.6.2.tar.gz", hash = "sha256:948e998cfc003681dc34f2d071c3a688cf35b805cbe107febbc488ef967ccba1", size = 22029, upload-time = "2023-08-03T07:31:06.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/0b/ab3ce2b27dd74b6a6703065bd304ea8211ff4de3b1c304446ed95234177b/construct_typing-0.6.2-py3-none-any.whl", hash = "sha256:ebea6989ac622d0c4eb457092cef0c7bfbcfa110bd018670fea7064d0bc09e47", size = 23298, upload-time = "2023-08-03T07:31:04.545Z" },
]
[[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, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "filelock"
version = "3.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "flask-cors"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/37/bcfa6c7d5eec777c4c7cf45ce6b27631cebe5230caf88d85eadd63edd37a/flask_cors-6.0.1.tar.gz", hash = "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db", size = 13463, upload-time = "2025-06-11T01:32:08.518Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload-time = "2025-06-11T01:32:07.352Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "identify"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jsonalias"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/45/ee7e17002cb7f3264f755ff6a1a72c55d1830e07808d643167d2a2277c4f/jsonalias-0.1.1.tar.gz", hash = "sha256:64f04d935397d579fc94509e1fcb6212f2d081235d9d6395bd10baedf760a769", size = 1095, upload-time = "2022-10-28T22:57:56.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" },
]
[[package]]
name = "markdown"
version = "3.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "nathanwoodburn-github-io"
version = "1.1.0"
source = { virtual = "." }
dependencies = [
{ name = "ansi2html" },
{ name = "beautifulsoup4" },
{ name = "cachetools" },
{ name = "cloudflare" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "gunicorn" },
{ name = "markdown" },
{ name = "pillow" },
{ name = "pydantic" },
{ name = "pygments" },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "qrcode" },
{ name = "requests" },
{ name = "solana" },
{ name = "solders" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "ansi2html", specifier = ">=1.9.2" },
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
{ name = "cachetools", specifier = ">=6.2.1" },
{ name = "cloudflare", specifier = ">=4.3.1" },
{ name = "flask", specifier = ">=3.1.2" },
{ name = "flask-cors", specifier = ">=6.0.1" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "markdown", specifier = ">=3.9" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pydantic", specifier = ">=2.12.3" },
{ name = "pygments", specifier = ">=2.19.2" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "qrcode", specifier = ">=8.2" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "solana", specifier = ">=0.36.9" },
{ name = "solders", specifier = ">=0.26.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.4.0" },
{ name = "ruff", specifier = ">=0.14.5" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]]
name = "pre-commit"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" },
]
[[package]]
name = "pydantic"
version = "2.12.3"
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/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
{ url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
{ url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
{ url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
{ url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
{ url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
{ url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
{ url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
{ url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
{ url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
{ url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
{ url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
{ url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
{ url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
{ url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
{ url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
{ url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
{ url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
{ url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[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, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[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, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ 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, upload-time = "2025-09-25T21:32:23.673Z" },
{ 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, upload-time = "2025-09-25T21:32:25.149Z" },
{ 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, upload-time = "2025-09-25T21:32:26.575Z" },
{ 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, upload-time = "2025-09-25T21:32:27.727Z" },
{ 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, upload-time = "2025-09-25T21:32:28.878Z" },
{ 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, upload-time = "2025-09-25T21:32:30.178Z" },
{ 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, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ 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, upload-time = "2025-09-25T21:32:35.712Z" },
{ 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, upload-time = "2025-09-25T21:32:36.789Z" },
{ 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, upload-time = "2025-09-25T21:32:37.966Z" },
{ 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, upload-time = "2025-09-25T21:32:39.178Z" },
{ 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, upload-time = "2025-09-25T21:32:40.865Z" },
{ 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, upload-time = "2025-09-25T21:32:42.084Z" },
{ 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, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ 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, upload-time = "2025-09-25T21:32:44.377Z" },
{ 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, upload-time = "2025-09-25T21:32:45.407Z" },
{ 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, upload-time = "2025-09-25T21:32:48.83Z" },
{ 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, upload-time = "2025-09-25T21:32:50.149Z" },
{ 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, upload-time = "2025-09-25T21:32:51.808Z" },
{ 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, upload-time = "2025-09-25T21:32:52.941Z" },
{ 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, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "ruff"
version = "0.14.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" },
{ url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" },
{ url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" },
{ url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" },
{ url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" },
{ url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" },
{ url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" },
{ url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" },
{ url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "solana"
version = "0.36.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct-typing" },
{ name = "httpx" },
{ name = "solders" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/e0/ce762b6763e3a0f8a5ccecbf695d65ef54b6f874ad5f58ce5cdcaba224f1/solana-0.36.9.tar.gz", hash = "sha256:f702f6177337c67a982909ef54ef3abce5e795b8cd93edb045bedfa4d13c20c5", size = 52722, upload-time = "2025-08-09T16:23:25.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/11/d5e5d02200ca85b615da39078806b377156b67b2093c8bc08a1b9c293070/solana-0.36.9-py3-none-any.whl", hash = "sha256:e05824f91f95abe5a687914976e8bc78986386156f2106108c696db998c3c542", size = 62882, upload-time = "2025-08-09T16:23:24.149Z" },
]
[[package]]
name = "solders"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonalias" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/96/23ad2e43e2676b78834064fe051e3db3ce1899336ecd4797f92fcd06113a/solders-0.26.0.tar.gz", hash = "sha256:057533892d6fa432c1ce1e2f5e3428802964666c10b57d3d1bcaab86295f046c", size = 181123, upload-time = "2025-02-18T19:23:57.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/ce/58bbb4d2c696e770cdd37e5f6dc2891ef7610c0c085bf400f9c42dcff1ad/solders-0.26.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9c1a0ef5daa1a05934af5fb6e7e32eab7c42cede406c80067fee006f461ffc4a", size = 24344472, upload-time = "2025-02-18T19:23:30.273Z" },
{ url = "https://files.pythonhosted.org/packages/5a/35/221cec0e5900c2202833e7e9110c3405a2d96ed25e110b247f88b8782e29/solders-0.26.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b964efbd7c0b38aef3bf4293ea5938517ae649b9a23e7cd147d889931775aab", size = 6674734, upload-time = "2025-02-18T19:23:35.15Z" },
{ url = "https://files.pythonhosted.org/packages/41/33/d17b7dbc92672351d59fc65cdb93b8924fc682deba09f6d96f25440187ae/solders-0.26.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e6a769c5298b887b7588edb171d93709a89302aef75913fe893d11c653739d", size = 13472961, upload-time = "2025-02-18T19:23:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e7/533367d815ab000587ccc37d89e154132f63347f02dcaaac5df72bd851de/solders-0.26.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b3cc55b971ec6ed1b4466fa7e7e09eee9baba492b8cd9e3204e3e1a0c5a0c4aa", size = 6886198, upload-time = "2025-02-18T19:23:41.453Z" },
{ url = "https://files.pythonhosted.org/packages/52/e0/ab41ab3df5fdf3b0e55613be93a43c2fe58b15a6ea8ceca26d3fba02e3c6/solders-0.26.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3e3973074c17265921c70246a17bcf80972c5b96a3e1ed7f5049101f11865092", size = 7319170, upload-time = "2025-02-18T19:23:43.758Z" },
{ url = "https://files.pythonhosted.org/packages/7d/34/5174ce592607e0ac020aff203217f2f113a55eec49af3db12945fea42d89/solders-0.26.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:59b52419452602f697e659199a25acacda8365971c376ef3c0687aecdd929e07", size = 7134977, upload-time = "2025-02-18T19:23:46.157Z" },
{ url = "https://files.pythonhosted.org/packages/ba/5e/822faabda0d473c29bdf59fe8869a411fd436af8ca6f5d6e89f7513f682f/solders-0.26.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5946ec3f2a340afa9ce5c2b8ab628ae1dea2ad2235551b1297cafdd7e3e5c51a", size = 6984222, upload-time = "2025-02-18T19:23:49.429Z" },
{ url = "https://files.pythonhosted.org/packages/23/e8/dc992f677762ea2de44b7768120d95887ef39fab10d6f29fb53e6a9882c1/solders-0.26.0-cp37-abi3-win_amd64.whl", hash = "sha256:5466616610170aab08c627ae01724e425bcf90085bc574da682e9f3bd954900b", size = 5480492, upload-time = "2025-02-18T19:23:53.285Z" },
]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[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, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "virtualenv"
version = "20.35.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
]