882 lines
28 KiB
Python
882 lines
28 KiB
Python
import json
|
|
from flask import (
|
|
Flask,
|
|
make_response,
|
|
redirect,
|
|
request,
|
|
jsonify,
|
|
render_template,
|
|
send_from_directory,
|
|
send_file,
|
|
)
|
|
from flask_cors import CORS
|
|
import os
|
|
import dotenv
|
|
import requests
|
|
import datetime
|
|
import qrcode
|
|
import re
|
|
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_H
|
|
from ansi2html import Ansi2HTMLConverter
|
|
from PIL import Image
|
|
from zoneinfo import ZoneInfo
|
|
import argparse
|
|
|
|
# Import blueprints
|
|
from blueprints import now, blog, wellknown, api, podcast, acme, spotify
|
|
from tools import (
|
|
isCLI,
|
|
getAddress,
|
|
getFilePath,
|
|
error_response,
|
|
getClientIP,
|
|
json_response,
|
|
getHandshakeScript,
|
|
get_tools_data,
|
|
)
|
|
from curl import curl_response
|
|
from cache_helper import (
|
|
get_git_latest_activity,
|
|
get_projects,
|
|
get_wallet_tokens,
|
|
get_coin_names,
|
|
get_wallet_domains,
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
CORS(app)
|
|
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 30 * (24 * 60 * 60) # 30 days in seconds
|
|
|
|
# Cache-busting and static file metadata cache
|
|
_STATIC_VERSION_CACHE: dict[str, tuple[int, int, str]] = {}
|
|
_HTML_URL_ATTR_RE = re.compile(
|
|
r'(?P<prefix>\b(?:src|href)=(["\']))(?P<url>/[^"\']+)(?P<suffix>\2)',
|
|
re.IGNORECASE,
|
|
)
|
|
_STYLESHEET_LINK_RE = re.compile(
|
|
r'<link\s+[^>]*rel=["\']stylesheet["\'][^>]*>',
|
|
re.IGNORECASE,
|
|
)
|
|
_HREF_ATTR_RE = re.compile(r'href=["\'](?P<href>[^"\']+)["\']', re.IGNORECASE)
|
|
|
|
# Register blueprints
|
|
for module in [now, blog, wellknown, api, podcast, acme, spotify]:
|
|
app.register_blueprint(module.app)
|
|
|
|
|
|
dotenv.load_dotenv()
|
|
|
|
# region Config/Constants
|
|
|
|
# Rate limiting for hosting enquiries
|
|
EMAIL_REQUEST_COUNT = {} # Track requests by email
|
|
IP_REQUEST_COUNT = {} # Track requests by IP
|
|
EMAIL_RATE_LIMIT = 3 # Max 3 requests per email per hour
|
|
IP_RATE_LIMIT = 5 # Max 5 requests per IP per hour
|
|
RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
|
|
|
|
RESTRICTED_ROUTES = ["ascii"]
|
|
REDIRECT_ROUTES = {
|
|
"contact": "/#contact",
|
|
"/meet": "https://cloud.woodburn.au/apps/calendar/appointment/PamrmmspWJZr",
|
|
"/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 = []
|
|
if os.path.isfile("data/sites.json"):
|
|
with open("data/sites.json") as file:
|
|
SITES = json.load(file)
|
|
# Remove any sites that are not enabled
|
|
SITES = [site for site in SITES if "enabled" not in site or site["enabled"]]
|
|
|
|
TZ = ZoneInfo(os.getenv("TIMEZONE", "Australia/Sydney"))
|
|
|
|
# endregion
|
|
|
|
|
|
# region request hooks
|
|
def _resolve_static_file(url_path: str) -> str | None:
|
|
clean_path = url_path.split("?", 1)[0].split("#", 1)[0]
|
|
|
|
if clean_path.startswith("/assets/"):
|
|
relative_path = clean_path[len("/assets/") :]
|
|
resolved = os.path.join("templates/assets", relative_path)
|
|
return resolved if os.path.isfile(resolved) else None
|
|
|
|
if clean_path.startswith("/fonts/"):
|
|
relative_path = clean_path[len("/fonts/") :]
|
|
resolved = os.path.join("templates/assets/fonts", relative_path)
|
|
return resolved if os.path.isfile(resolved) else None
|
|
|
|
if clean_path == "/manifest.json":
|
|
return "pwa/manifest.json"
|
|
|
|
if clean_path == "/sw.js":
|
|
return "pwa/sw.js"
|
|
|
|
if clean_path in ("/favicon.png", "/favicon.svg", "/favicon.ico"):
|
|
ext = clean_path.rsplit(".", 1)[-1]
|
|
resolved = f"templates/assets/img/favicon/favicon.{ext}"
|
|
return resolved if os.path.isfile(resolved) else None
|
|
|
|
if clean_path.count("/") == 1 and clean_path.endswith(".js"):
|
|
filename = clean_path.split("/")[-1]
|
|
resolved = os.path.join("templates/assets/js", filename)
|
|
return resolved if os.path.isfile(resolved) else None
|
|
|
|
return None
|
|
|
|
|
|
def _get_asset_version(url_path: str) -> str | None:
|
|
resolved = _resolve_static_file(url_path)
|
|
if not resolved:
|
|
return None
|
|
|
|
stat = os.stat(resolved)
|
|
mtime_ns = stat.st_mtime_ns
|
|
size = stat.st_size
|
|
|
|
cached = _STATIC_VERSION_CACHE.get(resolved)
|
|
if cached and cached[0] == mtime_ns and cached[1] == size:
|
|
return cached[2]
|
|
|
|
# Compact deterministic token derived from file metadata.
|
|
token = f"{mtime_ns:x}{size:x}"[-16:]
|
|
_STATIC_VERSION_CACHE[resolved] = (mtime_ns, size, token)
|
|
return token
|
|
|
|
|
|
def _append_cache_busting_to_html(html: str) -> str:
|
|
def _replace(match: re.Match[str]) -> str:
|
|
original_url = match.group("url")
|
|
if original_url.startswith("//"):
|
|
return match.group(0)
|
|
|
|
version = _get_asset_version(original_url)
|
|
if not version:
|
|
return match.group(0)
|
|
|
|
separator = "&" if "?" in original_url else "?"
|
|
if "?v=" in original_url or "&v=" in original_url:
|
|
return match.group(0)
|
|
|
|
cache_busted = f"{original_url}{separator}v={version}"
|
|
return f"{match.group('prefix')}{cache_busted}{match.group('suffix')}"
|
|
|
|
return _HTML_URL_ATTR_RE.sub(_replace, html)
|
|
|
|
|
|
def _is_non_blocking_stylesheet_target(href: str) -> bool:
|
|
normalized = href.lower().split("?", 1)[0]
|
|
|
|
# Keep the core layout CSS blocking.
|
|
if normalized.endswith("/assets/bootstrap/css/bootstrap.min.css"):
|
|
return False
|
|
if normalized.endswith("/assets/css/styles.min.css"):
|
|
return False
|
|
|
|
if "fonts.googleapis.com/css" in normalized:
|
|
return True
|
|
if normalized.startswith("/assets/fonts/"):
|
|
return True
|
|
|
|
return normalized in {
|
|
"/assets/css/brand-reveal.min.css",
|
|
"/assets/css/index.min.css",
|
|
"/assets/css/profile.min.css",
|
|
"/assets/css/social-icons.min.css",
|
|
"/assets/css/swiper.min.css",
|
|
}
|
|
|
|
|
|
def _async_stylesheet_tag(href: str) -> str:
|
|
return (
|
|
f'<link rel="preload" as="style" href="{href}">'
|
|
f'<link rel="stylesheet" href="{href}" media="print" onload="this.media=\'all\'">'
|
|
f'<noscript><link rel="stylesheet" href="{href}"></noscript>'
|
|
)
|
|
|
|
|
|
def _optimize_html_for_performance(html: str, path: str) -> str:
|
|
# Keep scope tight to avoid unexpected changes on unrelated pages.
|
|
if path != "/":
|
|
return html
|
|
|
|
if "fonts.googleapis.com/css" in html and "fonts.gstatic.com" not in html:
|
|
html = html.replace(
|
|
"</head>",
|
|
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
|
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
|
"</head>",
|
|
1,
|
|
)
|
|
|
|
def _replace_stylesheet(match: re.Match[str]) -> str:
|
|
tag = match.group(0)
|
|
href_match = _HREF_ATTR_RE.search(tag)
|
|
if not href_match:
|
|
return tag
|
|
|
|
href = href_match.group("href")
|
|
if 'media="print"' in tag or "media='print'" in tag:
|
|
return tag
|
|
|
|
if not _is_non_blocking_stylesheet_target(href):
|
|
return tag
|
|
|
|
return _async_stylesheet_tag(href)
|
|
|
|
html = _STYLESHEET_LINK_RE.sub(_replace_stylesheet, html)
|
|
|
|
# Replace icon-font navbar toggler to avoid dependency on icon fonts for first paint.
|
|
html = html.replace(
|
|
'<i class="fa fa-bars"></i>', '<span class="navbar-toggler-icon"></span>'
|
|
)
|
|
|
|
return html
|
|
|
|
|
|
def _is_static_request(path: str) -> bool:
|
|
return (
|
|
path.startswith("/assets/")
|
|
or path.startswith("/fonts/")
|
|
or path
|
|
in ("/manifest.json", "/sw.js", "/favicon.png", "/favicon.svg", "/favicon.ico")
|
|
or (path.count("/") == 1 and path.endswith(".js"))
|
|
)
|
|
|
|
|
|
@app.after_request
|
|
def add_header(response):
|
|
path = request.path
|
|
|
|
# HTML should be revalidated so clients can discover new cache-busted asset URLs quickly.
|
|
if response.status_code == 200 and response.mimetype == "text/html":
|
|
if not response.direct_passthrough:
|
|
html = response.get_data(as_text=True)
|
|
rewritten = _optimize_html_for_performance(html, path)
|
|
rewritten = _append_cache_busting_to_html(rewritten)
|
|
if rewritten != html:
|
|
response.set_data(rewritten)
|
|
|
|
response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
|
|
if request.method in ("GET", "HEAD"):
|
|
response.add_etag()
|
|
response.make_conditional(request)
|
|
return response
|
|
|
|
if _is_static_request(path):
|
|
if path == "/sw.js":
|
|
# Service workers should be revalidated frequently for update checks.
|
|
response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
|
|
elif request.args.get("v"):
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
else:
|
|
response.headers["Cache-Control"] = (
|
|
"public, max-age=604800, stale-while-revalidate=86400"
|
|
)
|
|
return response
|
|
|
|
if "Cache-Control" not in response.headers:
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
return response
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
# region Assets routes
|
|
@app.route("/assets/<path:path>")
|
|
def asset(path):
|
|
if path.endswith(".json"):
|
|
return send_from_directory(
|
|
"templates/assets", path, mimetype="application/json"
|
|
)
|
|
|
|
if os.path.isfile("templates/assets/" + path):
|
|
return send_from_directory("templates/assets", path)
|
|
|
|
# Custom matching for images
|
|
pathMap = {
|
|
"img/hns/w": "img/external/HNS/white",
|
|
"img/hns/b": "img/external/HNS/black",
|
|
"img/hns": "img/external/HNS/black",
|
|
}
|
|
|
|
for key in pathMap:
|
|
if path.startswith(key):
|
|
tmpPath = path.replace(key, pathMap[key])
|
|
if os.path.isfile("templates/assets/" + tmpPath):
|
|
return send_from_directory("templates/assets", tmpPath)
|
|
|
|
# Try looking in one of the directories
|
|
filename: str = path.split("/")[-1]
|
|
if (
|
|
filename.endswith(".png")
|
|
or filename.endswith(".jpg")
|
|
or filename.endswith(".jpeg")
|
|
or filename.endswith(".svg")
|
|
):
|
|
if os.path.isfile("templates/assets/img/" + filename):
|
|
return send_from_directory("templates/assets/img", filename)
|
|
if os.path.isfile("templates/assets/img/favicon/" + filename):
|
|
return send_from_directory("templates/assets/img/favicon", filename)
|
|
|
|
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.xml")
|
|
def sitemap():
|
|
# Remove all .html from sitemap
|
|
if not os.path.isfile("templates/sitemap.xml"):
|
|
return error_response(request)
|
|
with open("templates/sitemap.xml") as file:
|
|
sitemap = file.read()
|
|
|
|
sitemap = sitemap.replace(".html", "")
|
|
return make_response(sitemap, 200, {"Content-Type": "application/xml"})
|
|
|
|
|
|
@app.route("/favicon.<ext>")
|
|
def favicon(ext):
|
|
if ext not in ("png", "svg", "ico"):
|
|
return error_response(request)
|
|
return send_from_directory("templates/assets/img/favicon", f"favicon.{ext}")
|
|
|
|
|
|
@app.route("/<name>.js")
|
|
def javascript(name):
|
|
# Check if file in js directory
|
|
if not os.path.isfile("templates/assets/js/" + request.path.split("/")[-1]):
|
|
return error_response(request)
|
|
return send_from_directory("templates/assets/js", request.path.split("/")[-1])
|
|
|
|
|
|
@app.route("/download/<path:path>")
|
|
def download(path):
|
|
if path not in DOWNLOAD_ROUTES:
|
|
return error_response(request, message="Invalid download")
|
|
# Check if file exists
|
|
path = DOWNLOAD_ROUTES[path]
|
|
if os.path.isfile(path):
|
|
return send_file(path)
|
|
|
|
return error_response(request, message="File not found")
|
|
|
|
|
|
# endregion
|
|
# region PWA routes
|
|
|
|
|
|
@app.route("/manifest.json")
|
|
def manifest():
|
|
host = request.host
|
|
|
|
# Read as json
|
|
with open("pwa/manifest.json") as file:
|
|
manifest = json.load(file)
|
|
url = f"https://{host}"
|
|
if host == "localhost:5000" or host == "127.0.0.1:5000":
|
|
url = "http://127.0.0.1:5000"
|
|
|
|
manifest["start_url"] = url
|
|
manifest["scope"] = url
|
|
return jsonify(manifest)
|
|
|
|
|
|
@app.route("/sw.js")
|
|
def serviceWorker():
|
|
return send_from_directory("pwa", "sw.js")
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
# region Misc routes
|
|
@app.route("/links")
|
|
def links():
|
|
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>")
|
|
def api_legacy(function):
|
|
# Check if function is in api blueprint
|
|
for rule in app.url_map.iter_rules():
|
|
# Check if the redirect route exists
|
|
if rule.rule == f"/api/v1/{function}":
|
|
return redirect(f"/api/v1/{function}", code=301)
|
|
return error_response(request, message="404 Not Found", code=404)
|
|
|
|
|
|
# endregion
|
|
|
|
# region Main routes
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
# Check if host if podcast.woodburn.au
|
|
if "podcast.woodburn.au" in request.host:
|
|
return render_template("podcast.html")
|
|
|
|
if isCLI(request):
|
|
return curl_response(request)
|
|
|
|
# Use cached git data
|
|
git = get_git_latest_activity()
|
|
repo_name = git["repo"]["name"].lower()
|
|
repo_description = git["repo"]["description"]
|
|
|
|
# Use cached projects data
|
|
projects = get_projects(limit=3)
|
|
# Special names
|
|
if repo_name == "nathanwoodburn.github.io":
|
|
repo_name = "Nathan.Woodburn/"
|
|
|
|
html_url = git["repo"]["html_url"]
|
|
repo = '<a href="' + html_url + '" target="_blank">' + repo_name + "</a>"
|
|
|
|
timezone_offset = TZ.utcoffset(datetime.datetime.now()).total_seconds() / 3600 # type: ignore
|
|
time = datetime.datetime.now().strftime("%B %d")
|
|
time += """
|
|
<span id=\"time\"></span>
|
|
<script>
|
|
function startClock(timezoneOffset) {
|
|
function updateClock() {
|
|
const now = new Date();
|
|
const localTime = new Date(now.getTime() + timezoneOffset * 3600 * 1000);
|
|
const tzName = timezoneOffset >= 0 ? `UTC+${timezoneOffset}` : `UTC`;
|
|
const hours = String(localTime.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0');
|
|
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0');
|
|
const timeString = `${hours}:${minutes}:${seconds} ${tzName}`;
|
|
document.getElementById('time').textContent = timeString;
|
|
}
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
}
|
|
"""
|
|
time += f"startClock({timezone_offset});"
|
|
time += "</script>"
|
|
|
|
HNSaddress = getAddress("HNS")
|
|
SOLaddress = getAddress("SOL")
|
|
BTCaddress = getAddress("BTC")
|
|
ETHaddress = getAddress("ETH")
|
|
return render_template(
|
|
"index.html",
|
|
handshake_scripts=getHandshakeScript(request.host),
|
|
HNS=HNSaddress,
|
|
SOL=SOLaddress,
|
|
BTC=BTCaddress,
|
|
ETH=ETHaddress,
|
|
repo=repo,
|
|
repo_description=repo_description,
|
|
sites=SITES,
|
|
projects=projects,
|
|
time=time,
|
|
message="",
|
|
)
|
|
|
|
|
|
# region Donate
|
|
|
|
|
|
@app.route("/donate")
|
|
def donate():
|
|
if isCLI(request):
|
|
return curl_response(request)
|
|
|
|
coinList = os.listdir(".well-known/wallets")
|
|
coinList = [file for file in coinList if file[0] != "."]
|
|
coinList.sort()
|
|
|
|
tokenList = get_wallet_tokens()
|
|
coinNames = get_coin_names()
|
|
|
|
coins = ""
|
|
default_coins = ["btc", "eth", "hns", "sol", "xrp", "ada", "dot"]
|
|
|
|
for file in coinList:
|
|
coin_name = coinNames.get(file, file)
|
|
display_style = "" if file.lower() in default_coins else "display:none;"
|
|
coins += f'<a class="dropdown-item" style="{display_style}" href="?c={file.lower()}">{coin_name}</a>'
|
|
|
|
for token in tokenList:
|
|
chain_display = f" on {token['chain']}" if token["chain"] != "null" else ""
|
|
symbol_display = (
|
|
f" ({token['symbol']}{chain_display})"
|
|
if token["symbol"] != token["name"]
|
|
else chain_display
|
|
)
|
|
coins += f'<a class="dropdown-item" style="display:none;" href="?t={token["symbol"].lower()}&c={token["chain"].lower()}">{token["name"]}{symbol_display}</a>'
|
|
|
|
crypto = request.args.get("c")
|
|
if not crypto:
|
|
instructions = (
|
|
"<br>Donate with cryptocurrency:<br>Select a coin from the dropdown above."
|
|
)
|
|
return render_template(
|
|
"donate.html",
|
|
handshake_scripts=getHandshakeScript(request.host),
|
|
coins=coins,
|
|
default_coins=default_coins,
|
|
crypto=instructions,
|
|
)
|
|
crypto = crypto.upper()
|
|
|
|
token = request.args.get("t")
|
|
if token:
|
|
token = token.upper()
|
|
for t in tokenList:
|
|
if t["symbol"].upper() == token and t["chain"].upper() == crypto:
|
|
token = t
|
|
break
|
|
if not isinstance(token, dict):
|
|
token = {"name": "Unknown token", "symbol": token, "chain": crypto}
|
|
|
|
address = ""
|
|
cryptoHTML = ""
|
|
|
|
proof = ""
|
|
if os.path.isfile(f".well-known/wallets/.{crypto}.proof"):
|
|
proof = f'<a href="/.well-known/wallets/.{crypto}.proof" target="_blank"><img src="/assets/img/proof.png" alt="Proof of ownership" style="width: 100%; max-width: 30px; margin-left: 10px;"></a>'
|
|
|
|
if os.path.isfile(f".well-known/wallets/{crypto}"):
|
|
with open(f".well-known/wallets/{crypto}") as file:
|
|
address = file.read()
|
|
coin_display = coinNames.get(crypto, crypto)
|
|
if not token:
|
|
cryptoHTML += f"<br>Donate with {coin_display}:"
|
|
else:
|
|
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>'
|
|
|
|
if proof:
|
|
cryptoHTML += proof
|
|
elif token:
|
|
if "address" in token:
|
|
address = token["address"]
|
|
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>'
|
|
if proof:
|
|
cryptoHTML += proof
|
|
else:
|
|
cryptoHTML += f"<br>Invalid offchain token: {token['symbol']}<br>"
|
|
else:
|
|
cryptoHTML += f"<br>Invalid chain: {crypto}<br>"
|
|
|
|
domains = get_wallet_domains()
|
|
if crypto in domains:
|
|
domain = domains[crypto]
|
|
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>'
|
|
|
|
if address:
|
|
cryptoHTML += (
|
|
'<br><img src="/address/'
|
|
+ address
|
|
+ '" alt="QR Code" style="width: 100%; max-width: 200px; margin: 20px auto;">'
|
|
)
|
|
|
|
copyScript = '<script>document.getElementById("crypto-address").addEventListener("click", function() {navigator.clipboard.writeText(this.innerText);this.setAttribute("data-bs-original-title", "Copied!");const tooltips = document.querySelectorAll(".tooltip-inner");tooltips.forEach(tooltip => {tooltip.innerText = "Copied!";});});document.getElementById("crypto-domain").addEventListener("click", function() {navigator.clipboard.writeText(this.innerText);this.setAttribute("data-bs-original-title", "Copied!");const tooltips = document.querySelectorAll(".tooltip-inner");tooltips.forEach(tooltip => {tooltip.innerText = "Copied!";});});</script>'
|
|
cryptoHTML += copyScript
|
|
|
|
return render_template(
|
|
"donate.html",
|
|
handshake_scripts=getHandshakeScript(request.host),
|
|
crypto=cryptoHTML,
|
|
coins=coins,
|
|
default_coins=default_coins,
|
|
)
|
|
|
|
|
|
@app.route("/address/<path:address>")
|
|
def qraddress(address):
|
|
qr = qrcode.QRCode(
|
|
version=1,
|
|
error_correction=ERROR_CORRECT_L,
|
|
box_size=10,
|
|
border=4,
|
|
)
|
|
qr.add_data(address)
|
|
qr.make(fit=True)
|
|
qr_image = qr.make_image(fill_color="#110033", back_color="white")
|
|
|
|
# Save the QR code image to a temporary file
|
|
qr_image_path = "/tmp/qr_code.png"
|
|
qr_image.save(qr_image_path) # type: ignore
|
|
|
|
# Return the QR code image as a response
|
|
return send_file(qr_image_path, mimetype="image/png")
|
|
|
|
|
|
@app.route("/qrcode/<path:data>")
|
|
@app.route("/qr/<path:data>")
|
|
def qrcodee(data):
|
|
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_H, box_size=10, border=2)
|
|
qr.add_data(data)
|
|
qr.make()
|
|
|
|
qr_image: Image.Image = qr.make_image(
|
|
fill_color="black", back_color="white"
|
|
).convert("RGB") # type: ignore
|
|
|
|
# Add logo
|
|
logo = Image.open("templates/assets/img/favicon/logo.png")
|
|
basewidth = qr_image.size[0] // 3
|
|
wpercent = basewidth / float(logo.size[0])
|
|
hsize = int((float(logo.size[1]) * float(wpercent)))
|
|
logo = logo.resize((basewidth, hsize), Image.Resampling.LANCZOS)
|
|
pos = (
|
|
(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.save("/tmp/qr_code.png")
|
|
return send_file("/tmp/qr_code.png", mimetype="image/png")
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
@app.route("/supersecretpath")
|
|
def supersecretpath():
|
|
ascii_art = ""
|
|
if os.path.isfile("data/ascii.txt"):
|
|
with open("data/ascii.txt") as file:
|
|
ascii_art = file.read()
|
|
|
|
converter = Ansi2HTMLConverter()
|
|
ascii_art_html = converter.convert(ascii_art)
|
|
return render_template("ascii.html", ascii_art=ascii_art_html)
|
|
|
|
|
|
@app.route("/hosting/send-enquiry", methods=["POST"])
|
|
def hosting_post():
|
|
global EMAIL_REQUEST_COUNT
|
|
global IP_REQUEST_COUNT
|
|
|
|
if not request.is_json or not request.json:
|
|
return json_response(request, "No JSON data provided", 415)
|
|
|
|
# Keys
|
|
# email, cpus, memory, disk, backups, message
|
|
required_keys = ["email", "cpus", "memory", "disk", "backups", "message"]
|
|
for key in required_keys:
|
|
if key not in request.json:
|
|
return json_response(request, f"Missing key: {key}", 400)
|
|
|
|
email = request.json["email"]
|
|
ip = getClientIP(request)
|
|
print(f"Hosting enquiry from {email} ({ip})")
|
|
|
|
# Check rate limits
|
|
current_time = datetime.datetime.now().timestamp()
|
|
|
|
# Check email rate limit
|
|
if email in EMAIL_REQUEST_COUNT:
|
|
if (
|
|
current_time - EMAIL_REQUEST_COUNT[email]["last_reset"]
|
|
) > RATE_LIMIT_WINDOW:
|
|
# Reset counter if the time window has passed
|
|
EMAIL_REQUEST_COUNT[email] = {"count": 1, "last_reset": current_time}
|
|
else:
|
|
# Increment counter
|
|
EMAIL_REQUEST_COUNT[email]["count"] += 1
|
|
if EMAIL_REQUEST_COUNT[email]["count"] > EMAIL_RATE_LIMIT:
|
|
return json_response(
|
|
request, "Rate limit exceeded. Please try again later.", 429
|
|
)
|
|
else:
|
|
# First request for this email
|
|
EMAIL_REQUEST_COUNT[email] = {"count": 1, "last_reset": current_time}
|
|
|
|
# Check IP rate limit
|
|
if ip in IP_REQUEST_COUNT:
|
|
if (current_time - IP_REQUEST_COUNT[ip]["last_reset"]) > RATE_LIMIT_WINDOW:
|
|
# Reset counter if the time window has passed
|
|
IP_REQUEST_COUNT[ip] = {"count": 1, "last_reset": current_time}
|
|
else:
|
|
# Increment counter
|
|
IP_REQUEST_COUNT[ip]["count"] += 1
|
|
if IP_REQUEST_COUNT[ip]["count"] > IP_RATE_LIMIT:
|
|
return json_response(
|
|
request, "Rate limit exceeded. Please try again later.", 429
|
|
)
|
|
else:
|
|
# First request for this IP
|
|
IP_REQUEST_COUNT[ip] = {"count": 1, "last_reset": current_time}
|
|
|
|
cpus = request.json["cpus"]
|
|
memory = request.json["memory"]
|
|
disk = request.json["disk"]
|
|
backups = request.json["backups"]
|
|
message = request.json["message"]
|
|
|
|
# Try to convert to correct types
|
|
try:
|
|
cpus = int(cpus)
|
|
memory = float(memory)
|
|
disk = int(disk)
|
|
backups = backups in [True, "true", "True", 1, "1", "yes", "Yes"]
|
|
message = str(message)
|
|
email = str(email)
|
|
except ValueError:
|
|
return json_response(request, "Invalid data types", 400)
|
|
|
|
# Basic validation
|
|
if not isinstance(cpus, int) or cpus < 1 or cpus > 64:
|
|
return json_response(request, "Invalid CPUs", 400)
|
|
if not isinstance(memory, float) or memory < 0.5 or memory > 512:
|
|
return json_response(request, "Invalid memory", 400)
|
|
if not isinstance(disk, int) or disk < 10 or disk > 500:
|
|
return json_response(request, "Invalid disk", 400)
|
|
if not isinstance(backups, bool):
|
|
return json_response(request, "Invalid backups", 400)
|
|
if not isinstance(message, str) or len(message) > 1000:
|
|
return json_response(request, "Invalid message", 400)
|
|
if not isinstance(email, str) or len(email) > 100 or "@" not in email:
|
|
return json_response(request, "Invalid email", 400)
|
|
|
|
# Send to Discord webhook
|
|
webhook_url = os.getenv("HOSTING_WEBHOOK")
|
|
if not webhook_url:
|
|
return json_response(request, "Hosting webhook not set", 500)
|
|
|
|
data = {
|
|
"content": "",
|
|
"embeds": [
|
|
{
|
|
"title": "Hosting Enquiry",
|
|
"description": f"Email: {email}\nCPUs: {cpus}\nMemory: {memory}GB\nDisk: {disk}GB\nBackups: {backups}\nMessage: {message}",
|
|
"color": 16711680, # Red color
|
|
}
|
|
],
|
|
}
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
response = requests.post(webhook_url, json=data, headers=headers)
|
|
if response.status_code != 204 and response.status_code != 200:
|
|
return json_response(request, "Failed to send enquiry", 500)
|
|
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")
|
|
def resume_pdf():
|
|
# Check if arg for support is passed
|
|
support = request.args.get("support")
|
|
if support:
|
|
return send_file("data/resume_support.pdf")
|
|
return send_file("data/resume.pdf")
|
|
|
|
|
|
@app.route("/tools")
|
|
def tools():
|
|
if isCLI(request):
|
|
return curl_response(request)
|
|
return render_template("tools.html", tools=get_tools_data())
|
|
|
|
|
|
# endregion
|
|
# region Error Catching
|
|
|
|
|
|
# Catch all for GET requests
|
|
@app.route("/<path:path>")
|
|
def catch_all(path: str):
|
|
if path.lower().replace(".html", "") in RESTRICTED_ROUTES:
|
|
return error_response(request, message="Restricted route", code=403)
|
|
|
|
# If curl request, return curl response
|
|
if isCLI(request):
|
|
return curl_response(request)
|
|
|
|
if path in REDIRECT_ROUTES:
|
|
return redirect(REDIRECT_ROUTES[path], code=302)
|
|
|
|
# If file exists, load it
|
|
if os.path.isfile("templates/" + path):
|
|
return render_template(
|
|
path, handshake_scripts=getHandshakeScript(request.host), sites=SITES
|
|
)
|
|
|
|
# Try with .html
|
|
if os.path.isfile("templates/" + path + ".html"):
|
|
return render_template(
|
|
path + ".html",
|
|
handshake_scripts=getHandshakeScript(request.host),
|
|
sites=SITES,
|
|
)
|
|
|
|
if os.path.isfile("templates/" + path.strip("/") + ".html"):
|
|
return render_template(
|
|
path.strip("/") + ".html",
|
|
handshake_scripts=getHandshakeScript(request.host),
|
|
sites=SITES,
|
|
)
|
|
|
|
# Try to find a file matching
|
|
if path.count("/") < 1:
|
|
# Try to find a file matching
|
|
filename = getFilePath(path, "templates")
|
|
if filename:
|
|
return send_file(filename)
|
|
|
|
return error_response(request)
|
|
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
return error_response(request)
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# If --host argument is passed, use that as host, otherwise use 127.0.0.1
|
|
parser = argparse.ArgumentParser(description="Run the Flask server.")
|
|
parser.add_argument("--host", type=str, default="127.0.0.1")
|
|
|
|
args = parser.parse_args()
|
|
|
|
app.run(debug=True, port=5000, host=args.host)
|