28 Commits

Author SHA1 Message Date
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
26 changed files with 496 additions and 1368 deletions

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 .

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,7 +5,7 @@ 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
@@ -17,9 +17,9 @@ 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 # Load configuration
NC_CONFIG = requests.get( NC_CONFIG = requests.get(
@@ -30,8 +30,8 @@ if 'time-zone' not in NC_CONFIG:
NC_CONFIG['time-zone'] = 10 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 +40,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 +47,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,18 +57,18 @@ 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"]) timezone_offset = datetime.timedelta(hours=NC_CONFIG["time-zone"])
@@ -83,7 +84,7 @@ def time():
}) })
@api_bp.route("/timezone") @app.route("/timezone")
def timezone(): def timezone():
"""Get the current timezone setting.""" """Get the current timezone setting."""
return jsonify({ return jsonify({
@@ -93,7 +94,7 @@ def timezone():
}) })
@api_bp.route("/message") @app.route("/message")
def message(): def message():
"""Get the message from the configuration.""" """Get the message from the configuration."""
return jsonify({ return jsonify({
@@ -103,7 +104,7 @@ def message():
}) })
@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 +113,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,7 +135,7 @@ 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 = { gitinfo = {
@@ -167,7 +168,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 +177,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 +187,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,9 +3,9 @@ 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 tools import isCLI, getClientIP, getHandshakeScript
blog_bp = Blueprint('blog', __name__) app = Blueprint('blog', __name__, url_prefix='/blog')
def list_page_files(): def list_page_files():
@@ -108,9 +108,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,9 +129,9 @@ 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 # Convert md to html
@@ -152,7 +152,7 @@ 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"): if not os.path.exists(f"data/blog/{path}.md"):
return render_template("404.html"), 404 return render_template("404.html"), 404

View File

@@ -1,10 +1,13 @@
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 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')
def list_page_files(): def list_page_files():
@@ -44,27 +47,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 +171,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 +190,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")
@@ -48,7 +48,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 +56,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,34 +89,11 @@ 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():
@@ -137,12 +114,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:

45
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 import requests
from blueprints.spotify import get_spotify_track from blueprints.spotify import get_spotify_track
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
@@ -77,35 +79,6 @@ def get_projects():
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 +117,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'}

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",

View File

@@ -19,30 +19,17 @@ 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 isCLI, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getHandshakeScript, get_tools_data
from blueprints.wellknown import wk_bp from curl import curl_response
from blueprints.api import api_bp
from blueprints.podcast import podcast_bp
from blueprints.acme import acme_bp
from blueprints.spotify import spotify_bp
from blueprints.terminal import terminal_bp
from tools import isCurl, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getHandshakeScript, get_tools_data
from curl import curl_response, valid_curl_path
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,7 +44,11 @@ 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",
"/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 = { DOWNLOAD_ROUTES = {
"pgp": "data/nathanwoodburn.asc" "pgp": "data/nathanwoodburn.asc"
@@ -190,21 +181,15 @@ def serviceWorker():
# 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):
@@ -215,13 +200,6 @@ def api_legacy(function):
return redirect(f"/api/v1/{function}", code=301) return redirect(f"/api/v1/{function}", code=301)
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
@@ -247,7 +225,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):
@@ -265,7 +243,7 @@ def index():
try: try:
git = requests.get( git = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1", "https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1",
headers={"Authorization": os.getenv("GIT_AUTH") if os.getenv("GIT_AUTH") else os.getenv("git_token")}, headers={"Authorization": os.getenv("GIT_AUTH")},
) )
git = git.json() git = git.json()
git = git[0] git = git[0]
@@ -388,7 +366,7 @@ def index():
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"},
@@ -398,9 +376,11 @@ 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")
@@ -563,6 +543,7 @@ def qrcodee(data):
# endregion # endregion
@app.route("/supersecretpath") @app.route("/supersecretpath")
def supersecretpath(): def supersecretpath():
ascii_art = "" ascii_art = ""
@@ -688,9 +669,10 @@ def resume_pdf():
return send_file("data/resume.pdf") return send_file("data/resume.pdf")
return error_response(request, message="Resume not found") 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())
@@ -698,8 +680,6 @@ def tools():
# 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):
@@ -707,7 +687,7 @@ def catch_all(path: str):
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:

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}}

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,49 +94,110 @@
<!-- 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>
<div class="modal-footer"><button class="btn btn-light" type="button" data-bs-dismiss="modal">Close</button></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>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const navbar = document.getElementById('mainNav'); const navbar = document.getElementById('mainNav');
const headers = document.querySelectorAll('.section-header'); const headers = document.querySelectorAll('.section-header');
if (navbar) { if (navbar) {
const navbarHeight = navbar.offsetHeight; const navbarHeight = navbar.offsetHeight;
headers.forEach(header => { headers.forEach(header => {
header.style.top = navbarHeight + 'px'; header.style.top = navbarHeight + 'px';
header.style.zIndex = '100'; header.style.zIndex = '100';
header.style.scrollMarginTop = navbarHeight + 'px'; header.style.scrollMarginTop = navbarHeight + 'px';
}); });
// Handle hash navigation on page load // Handle hash navigation on page load
if (window.location.hash) { if (window.location.hash) {
setTimeout(() => { setTimeout(() => {
const target = document.querySelector(window.location.hash); const target = document.querySelector(window.location.hash);
if (target) { if (target) {
window.scrollTo({ window.scrollTo({
top: target.offsetTop - navbarHeight, top: target.offsetTop - navbarHeight,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
}, 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

@@ -6,10 +6,15 @@ GET http://127.0.0.1:5000/api/v1/ip
HTTP 200 HTTP 200
[Asserts] [Asserts]
jsonpath "$.ip" == "127.0.0.1" jsonpath "$.ip" == "127.0.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

@@ -27,6 +27,14 @@ CRAWLERS = [
"Twitterbot" "Twitterbot"
] ]
CLI_AGENTS = [
"curl",
"hurl",
"xh",
"Posting",
"HTTPie"
]
def getClientIP(request: Request) -> str: def getClientIP(request: Request) -> str:
""" """
@@ -75,7 +83,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 +95,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
@@ -202,7 +210,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 +228,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