Technical Paper — Architecture, Features, and Implementation
lilacrose.dev is my personal site, styled after the terminal interfaces from the game SIGNALIS. Everything on it — the aesthetic, the layout, the features — was designed and built entirely by me with no AI assistance. It runs on a VPS behind nginx, with the application layer written in Python using the Quart async web framework (an async port of Flask). Redis handles caching and session state.
The site has a handful of distinct systems: a terminal emulator, a live Last.fm music tracker, a daily fractal generator, a guestbook with spam filtering, a writing archive, a budget tracker, and a papercraft community canvas called Paper Lily Place. Each major feature is its own Quart blueprint, keeping the codebase modular and the main server file small.
The site runs as a systemd service on a VPS, proxied through nginx. nginx handles SSL termination (Let's Encrypt), static file serving for assets that don't change, and reverse-proxying dynamic requests to the Quart process running on a local port. The portfolio app runs as a separate process on a different port, also proxied by nginx under a subdomain.
| Component | Role |
|---|---|
| nginx | SSL termination, static files, reverse proxy |
| Quart (Python) | Async web framework — routes, blueprints, API |
| Redis | Last.fm cache, session data, rate limiting |
| APScheduler | Midnight fractal generation |
| aiohttp | Async HTTP for Last.fm, external API calls |
Each feature registers as a Quart blueprint. This means the routes, templates, and static
files for, say, the fractal generator live in fractals/ and don't touch the main
server file. The main server.py only imports and registers them.
# server.py — blueprint registration from budget_tracker import budget_bp from fractals import fractal_bp from music import music_bp from musings import musings_bp from paperlilyplace import place_bp from arg.arg import arg_bp app.register_blueprint(budget_bp) app.register_blueprint(fractal_bp) app.register_blueprint(music_bp) app.register_blueprint(musings_bp) app.register_blueprint(place_bp) app.register_blueprint(arg_bp)
The main page has an interactive command-line terminal styled after SIGNALIS's LSTR OS. It accepts typed commands and responds with styled output — links to other sections, status readouts, and a few easter eggs. The terminal is pure front-end JavaScript: no server round-trips for command processing. Commands are matched against a static dispatch table and the output is injected into the terminal's scrolling history.
Each command is an entry in a commands object mapping command strings to handler
functions. Unknown commands produce a styled error. The terminal input captures Enter
to submit, ↑/↓ for history navigation, and Tab for completion.
const commands = { 'help': () => [ 'Available commands:', ' ls — list sections', ' cd <dir> — navigate', ' whoami — system info', ' music — now playing', ' fractal — today\'s fractal', ' clear — clear terminal', ], 'ls': () => [ 'drwxr-xr-x portfolio/', 'drwxr-xr-x writing/', 'drwxr-xr-x musings/', 'drwxr-xr-x fractal/', '-rw-r--r-- guestbook.txt', ], 'whoami': () => ['OPERATOR: LILAC ROSE', 'STATUS: OPERATIONAL'], 'clear': () => { clearTerminal(); return []; }, // navigation commands generated from the ls output 'cd portfolio': () => { window.location = '/portfolio'; return ['Navigating...']; }, }; function runCommand(input) { const cmd = input.trim().toLowerCase(); const handler = commands[cmd]; if (handler) { const output = handler(); output.forEach(line => appendLine(line)); } else { appendLine(`command not found: ${cmd}`, 'error'); } }
The terminal keeps a rolling command history array. Arrow-key events shift an index pointer into the array and fill the input field, matching behaviour users expect from a real terminal.
let history = [], histIdx = -1; input.addEventListener('keydown', e => { if (e.key === 'Enter') { const val = input.value.trim(); if (val) { history.unshift(val); histIdx = -1; } appendPrompt(val); runCommand(val); input.value = ''; } else if (e.key === 'ArrowUp') { e.preventDefault(); if (histIdx < history.length - 1) histIdx++; input.value = history[histIdx] ?? ''; } else if (e.key === 'ArrowDown') { e.preventDefault(); if (histIdx > -1) histIdx--; input.value = histIdx === -1 ? '' : history[histIdx]; } });
The music section shows what I'm currently listening to (or most recently played) via the Last.fm API. The front end polls a local API endpoint every 30 seconds; the server caches Last.fm responses in Redis for 25 seconds to avoid hammering the external API. If the Last.fm API is unavailable, the cached value is served stale rather than erroring.
The cache key is a fixed string — there are no per-user variations since this is always my own Last.fm data. The TTL is set slightly below the poll interval so the cache is almost always warm when a client polls, but stale data is never older than a couple of minutes.
LASTFM_CACHE_KEY = "lastfm:recent_tracks" CACHE_TTL = 25 # seconds — client polls every 30 @music_bp.route('/api/now-playing') async def now_playing(): cached = redis_client.get(LASTFM_CACHE_KEY) if cached: return jsonify(json.loads(cached)) async with aiohttp.ClientSession() as session: async with session.get(LASTFM_API_URL, params={ 'method': 'user.getrecenttracks', 'user': LASTFM_USER, 'api_key': LASTFM_KEY, 'format': 'json', 'limit': 5, }) as resp: data = await resp.json() tracks = data['recenttracks']['track'] result = { 'now_playing': tracks[0].get('@attr', {}).get('nowplaying') == 'true', 'track': tracks[0]['name'], 'artist': tracks[0]['artist']['#text'], 'album': tracks[0]['album']['#text'], 'image': tracks[0]['image'][-1]['#text'], 'recent': [{ 'track': t['name'], 'artist': t['artist']['#text'], } for t in tracks[1:5]], } redis_client.setex(LASTFM_CACHE_KEY, CACHE_TTL, json.dumps(result)) return jsonify(result)
The music widget updates itself every 30 seconds using setInterval. When a
track is currently playing, the widget shows a pulsing indicator; when the most recent track
ended more than a few minutes ago it shows "recently played". Album art is loaded lazily with
a fallback placeholder for tracks with no artwork.
async function updateNowPlaying() { const data = await fetch('/music/api/now-playing').then(r => r.json()); document.getElementById('track-name').textContent = data.track; document.getElementById('artist-name').textContent = data.artist; const indicator = document.getElementById('status-dot'); if (data.now_playing) { indicator.classList.add('pulsing'); indicator.title = 'Now playing'; } else { indicator.classList.remove('pulsing'); indicator.title = 'Recently played'; } } updateNowPlaying(); setInterval(updateNowPlaying, 30_000);
The guestbook lets visitors leave short signed messages. Entries are stored in a SQLite database with a timestamp, display name, and message. There's a rate limit per IP (stored in Redis) and a basic content filter that blocks obvious spam patterns. Messages are shown in reverse chronological order with pagination.
Each IP is allowed one guestbook entry per hour. The rate limit is implemented as a Redis key with a 3600-second TTL. On each submission, the key is checked first; if it exists, the request is rejected with a message. If it doesn't exist, the entry is written and the key is set. This is a simple atomic check — not a sliding window — which is fine for a low-traffic personal site.
RATELIMIT_TTL = 3600 # one entry per IP per hour @app.route('/guestbook', methods=['POST']) async def guestbook_post(): ip = request.remote_addr key = f"guestbook:ratelimit:{ip}" if redis_client.get(key): return jsonify({'error': 'One entry per hour.'}), 429 form = await request.form name = form.get('name', 'Anonymous').strip()[:32] message = form.get('message', '').strip()[:280] if not message or is_spam(message): return jsonify({'error': 'Message rejected.'}), 400 conn = get_db() conn.execute( 'INSERT INTO guestbook (name, message, ip_hash, created_at) VALUES (?,?,?,?)', (name, message, hashlib.sha256(ip.encode()).hexdigest(), datetime.utcnow().isoformat()) ) conn.commit() redis_client.setex(key, RATELIMIT_TTL, 1) return jsonify({'ok': True})
IPs are stored as SHA-256 hashes, not plaintext, so the database doesn't contain identifiable visitor data. The hash is only used for rate limit lookups — it can't be reversed to an IP address.
The musings section is a writing archive for my short fiction and other pieces. Each post
is stored as a Markdown file in a posts/ directory, with frontmatter (title,
date, tags) parsed at read time. The blueprint scans the directory on startup to build
an in-memory index, then serves posts by ID without hitting disk on every request for the
listing page.
Posts use a simple frontmatter format: ----delimited YAML at the top of a
Markdown file. The parser splits on the first two --- lines, reads the YAML block,
and passes the rest to a Markdown renderer. The ID used in URLs is derived from the filename
so posts are addressable without a database.
def load_post(filename: str) -> dict: path = os.path.join(POSTS_DIR, filename) with open(path, encoding='utf-8') as f: raw = f.read() # split frontmatter from body _, fm_raw, body = raw.split('---', 2) meta = yaml.safe_load(fm_raw) return { 'id': filename.removesuffix('.md'), 'title': meta['title'], 'date': meta['date'], 'tags': meta.get('tags', []), 'summary': meta.get('summary', ''), 'body': markdown.markdown(body.strip(), extensions=['extra']), } def build_index() -> list: files = sorted( (f for f in os.listdir(POSTS_DIR) if f.endswith('.md')), reverse=True # newest first — filenames start with date (YYYYMMDD_HHMMSS) ) return [load_post(f) for f in files]
The listing page uses the pre-built index. Individual post pages look up by ID with a dict comprehension over the index rather than a linear search — the index is a list, so the dict is built once per process startup.
_index = build_index()
_by_id = {p['id']: p for p in _index}
@musings_bp.route('/musings')
async def musings_index():
return await render_template('musings.html', posts=_index)
@musings_bp.route('/musings/read/<post_id>')
async def read_post(post_id):
post = _by_id.get(post_id)
if not post:
return await render_template('404.html'), 404
return await render_template('post.html', post=post)
Paper Lily Place is a collaborative canvas inspired by r/place. Visitors can paint individual pixels on a shared grid, one pixel at a time. The canvas state is stored as a flat binary buffer in Redis (one byte per pixel, encoding a colour index) so reads and writes are O(1) array ops rather than database queries. Changes are broadcast to connected clients in real time via server-sent events.
The canvas is a fixed-size grid. Each pixel is one byte representing an index into a
predefined colour palette. The entire canvas is stored as a single Redis key — a binary string
of width × height bytes. Reading the full canvas is a single GET; writing a pixel
is a single SETRANGE at the byte offset y * width + x.
WIDTH, HEIGHT = 200, 150 CANVAS_KEY = "place:canvas" def init_canvas(): # initialise to all-white (index 0) if not already set if not redis_client.exists(CANVAS_KEY): redis_client.set(CANVAS_KEY, bytes(WIDTH * HEIGHT)) def get_canvas() -> bytes: return redis_client.get(CANVAS_KEY) def set_pixel(x: int, y: int, colour_idx: int): offset = y * WIDTH + x redis_client.setrange(CANVAS_KEY, offset, bytes([colour_idx]))
Server-sent events push pixel updates to all connected clients without polling. When a pixel is painted, the change is written to Redis and then broadcast to an in-memory event queue shared across all SSE subscribers. Quart's async generator support makes the SSE endpoint clean — the route is an async generator that yields events as they arrive.
_subscribers: list[asyncio.Queue] = [] async def broadcast(event: dict): for q in list(_subscribers): await q.put(event) @place_bp.route('/place/api/paint', methods=['POST']) async def paint(): data = await request.get_json() x, y, colour = data['x'], data['y'], data['colour'] set_pixel(x, y, colour) await broadcast({'x': x, 'y': y, 'colour': colour}) return jsonify({'ok': True}) @place_bp.route('/place/api/events') async def sse(): q: asyncio.Queue = asyncio.Queue() _subscribers.append(q) async def generate(): try: while True: event = await q.get() yield f"data: {json.dumps(event)}\n\n" finally: _subscribers.remove(q) return app.response_class(generate(), mimetype='text/event-stream')
nginx serves static assets (images, fonts) with long-lived cache headers since those files don't change. HTML is never cached — every page request hits the app. CSS and JS use no-cache with revalidation rather than a TTL, so updates take effect immediately but the browser can still use a cached version if the content hash matches.
@app.after_request async def add_cache_headers(response): if request.path.endswith('.html') or request.path == '/': # never cache HTML — always fresh response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' response.headers['Pragma'] = 'no-cache' elif request.path.endswith(('.css', '.js')): # revalidate on every request, but allow conditional GET response.headers['Cache-Control'] = 'no-cache, must-revalidate, max-age=0' elif request.path.startswith('/static/'): # images and fonts: cache for 30 days response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' return response