patx/relay-lang

(examples): add Relay URL shortener with templates, CSRF, and rate limiting

Commit 1ef7ca4 · patx · 2026-02-12T22:46:35-05:00

Changeset
1ef7ca4655102b7968e44ac9a55aaa2304e26fae
Parents
8ce36cbdde24c8f4a62be99e6a0d61a50b3ef472

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/examples/templates/url_shortener_400.html b/examples/templates/url_shortener_400.html
new file mode 100644
index 0000000..f5581f8
--- /dev/null
+++ b/examples/templates/url_shortener_400.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>400 Bad Request</title>
+  <style>
+    :root {
+      color-scheme: dark;
+      --bg: #0d1117;
+      --card: #161b22;
+      --border: #30363d;
+      --text: #c9d1d9;
+      --muted: #8b949e;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: radial-gradient(circle at top left, #1f2733 0%, var(--bg) 45%);
+      color: var(--text);
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      padding: 24px;
+    }
+
+    main {
+      width: 100%;
+      max-width: 640px;
+      background: var(--card);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 20px;
+    }
+
+    h1 { margin: 0 0 8px; }
+    p { color: var(--muted); margin: 6px 0; }
+    a { color: #9ecbff; text-decoration: none; }
+    code {
+      background: #0f141b;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      padding: 1px 5px;
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>400 Bad Request</h1>
+    <p>The submitted URL was invalid.</p>
+    <p>URLs must start with <code>http://</code> or <code>https://</code>.</p>
+    <p><a href="/">Back to shortener</a></p>
+  </main>
+</body>
+</html>
diff --git a/examples/templates/url_shortener_404.html b/examples/templates/url_shortener_404.html
new file mode 100644
index 0000000..d369ebd
--- /dev/null
+++ b/examples/templates/url_shortener_404.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>404 Not Found</title>
+  <style>
+    :root {
+      color-scheme: dark;
+      --bg: #0d1117;
+      --card: #161b22;
+      --border: #30363d;
+      --text: #c9d1d9;
+      --muted: #8b949e;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: radial-gradient(circle at top left, #1f2733 0%, var(--bg) 45%);
+      color: var(--text);
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      padding: 24px;
+    }
+
+    main {
+      width: 100%;
+      max-width: 640px;
+      background: var(--card);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 20px;
+    }
+
+    h1 { margin: 0 0 8px; }
+    p { color: var(--muted); margin: 6px 0; }
+    a { color: #9ecbff; text-decoration: none; }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>404 Not Found</h1>
+    <p>The requested short URL does not exist.</p>
+    <p><a href="/">Back to shortener</a></p>
+  </main>
+</body>
+</html>
diff --git a/examples/templates/url_shortener_index.html b/examples/templates/url_shortener_index.html
new file mode 100644
index 0000000..86166f1
--- /dev/null
+++ b/examples/templates/url_shortener_index.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Relay URL Shortener</title>
+  <style>
+    :root {
+      color-scheme: dark;
+      --bg: #0d1117;
+      --card: #161b22;
+      --border: #30363d;
+      --text: #c9d1d9;
+      --muted: #8b949e;
+      --accent: #58a6ff;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: radial-gradient(circle at top left, #1f2733 0%, var(--bg) 45%);
+      color: var(--text);
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      padding: 24px;
+    }
+
+    main {
+      width: 100%;
+      max-width: 680px;
+      background: var(--card);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 20px;
+    }
+
+    h1 {
+      margin: 0 0 10px;
+      font-size: 1.2rem;
+    }
+
+    p {
+      margin: 0 0 16px;
+      color: var(--muted);
+      font-size: 0.92rem;
+      line-height: 1.4;
+    }
+
+    form {
+      display: flex;
+      gap: 10px;
+    }
+
+    input {
+      flex: 1;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #0f141b;
+      color: var(--text);
+      padding: 10px 12px;
+      font: inherit;
+    }
+
+    button {
+      border: 1px solid #2f81f7;
+      background: var(--accent);
+      color: #0d1117;
+      border-radius: 8px;
+      font: inherit;
+      font-weight: 700;
+      padding: 10px 14px;
+      cursor: pointer;
+    }
+
+    code {
+      background: #0f141b;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      padding: 2px 6px;
+      color: #9ecbff;
+    }
+
+    @media (max-width: 600px) {
+      form { flex-direction: column; }
+      button { width: 100%; }
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>Relay URL Shortener</h1>
+    <p>Paste a URL to create a short link.</p>
+    <form method="post" action="/">
+      <input name="url" type="url" placeholder="https://example.com/some/long/path" required />
+      <input name="csrf_token" type="hidden" value="{{ csrf_token }}" />
+      <button type="submit">Shorten</button>
+    </form>
+    <p style="margin-top: 14px;">
+      API: <code>POST /api/v1/shorten</code> with JSON <code>{"url":"https://example.com"}</code>
+    </p>
+  </main>
+</body>
+</html>
diff --git a/examples/templates/url_shortener_stats.html b/examples/templates/url_shortener_stats.html
new file mode 100644
index 0000000..5561c1c
--- /dev/null
+++ b/examples/templates/url_shortener_stats.html
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Short URL Stats</title>
+  <style>
+    :root {
+      color-scheme: dark;
+      --bg: #0d1117;
+      --card: #161b22;
+      --border: #30363d;
+      --text: #c9d1d9;
+      --muted: #8b949e;
+      --accent: #58a6ff;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: radial-gradient(circle at top left, #1f2733 0%, var(--bg) 45%);
+      color: var(--text);
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      padding: 24px;
+    }
+
+    main {
+      width: 100%;
+      max-width: 760px;
+      background: var(--card);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 20px;
+    }
+
+    h1 { margin-top: 0; font-size: 1.2rem; }
+    p { color: var(--muted); }
+
+    .item {
+      margin: 14px 0;
+      padding: 10px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #0f141b;
+      overflow-wrap: anywhere;
+    }
+
+    .label {
+      font-size: 0.82rem;
+      color: var(--muted);
+      margin-bottom: 4px;
+    }
+
+    .value {
+      color: var(--text);
+      text-decoration: none;
+    }
+
+    a.value { color: #9ecbff; }
+
+    .clicks {
+      font-size: 1.1rem;
+      font-weight: 700;
+      color: #79c0ff;
+    }
+
+    .actions {
+      margin-top: 18px;
+      display: flex;
+      gap: 12px;
+      flex-wrap: wrap;
+    }
+
+    .btn {
+      border: 1px solid #2f81f7;
+      background: var(--accent);
+      color: #0d1117;
+      text-decoration: none;
+      border-radius: 8px;
+      font-weight: 700;
+      padding: 10px 12px;
+      display: inline-block;
+    }
+
+    .btn.secondary {
+      background: transparent;
+      color: var(--text);
+      border-color: var(--border);
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>Link Stats</h1>
+    <p>Usage details for this short URL.</p>
+
+    <div class="item">
+      <div class="label">Short URL</div>
+      <a class="value" href="{{ short_url }}">{{ short_url }}</a>
+    </div>
+
+    <div class="item">
+      <div class="label">Destination URL</div>
+      <a class="value" href="{{ url }}">{{ url }}</a>
+    </div>
+
+    <div class="item">
+      <div class="label">Clicks</div>
+      <span class="clicks">{{ clicks }}</span>
+    </div>
+
+    <div class="actions">
+      <a class="btn" href="/">Shorten Another</a>
+      <a class="btn secondary" href="{{ short_url }}">Open Short URL</a>
+    </div>
+  </main>
+</body>
+</html>
diff --git a/examples/templates/url_shortener_success.html b/examples/templates/url_shortener_success.html
new file mode 100644
index 0000000..302e329
--- /dev/null
+++ b/examples/templates/url_shortener_success.html
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Short URL Created</title>
+  <style>
+    :root {
+      color-scheme: dark;
+      --bg: #0d1117;
+      --card: #161b22;
+      --border: #30363d;
+      --text: #c9d1d9;
+      --muted: #8b949e;
+      --accent: #58a6ff;
+    }
+
+    * { box-sizing: border-box; }
+
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: radial-gradient(circle at top left, #1f2733 0%, var(--bg) 45%);
+      color: var(--text);
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+      padding: 24px;
+    }
+
+    main {
+      width: 100%;
+      max-width: 760px;
+      background: var(--card);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      padding: 20px;
+    }
+
+    h1 { margin-top: 0; font-size: 1.2rem; }
+    p { color: var(--muted); }
+
+    .item {
+      margin: 14px 0;
+      padding: 10px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #0f141b;
+      overflow-wrap: anywhere;
+    }
+
+    .label {
+      font-size: 0.82rem;
+      color: var(--muted);
+      margin-bottom: 4px;
+    }
+
+    .value {
+      color: var(--text);
+      text-decoration: none;
+    }
+
+    a.value { color: #9ecbff; }
+
+    .actions {
+      margin-top: 18px;
+      display: flex;
+      gap: 12px;
+      flex-wrap: wrap;
+    }
+
+    .btn {
+      border: 1px solid #2f81f7;
+      background: var(--accent);
+      color: #0d1117;
+      text-decoration: none;
+      border-radius: 8px;
+      font-weight: 700;
+      padding: 10px 12px;
+      display: inline-block;
+    }
+
+    .btn.secondary {
+      background: transparent;
+      color: var(--text);
+      border-color: var(--border);
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>Short URL Created</h1>
+    <p>Your link is ready.</p>
+
+    <div class="item">
+      <div class="label">Short URL</div>
+      <a class="value" href="{{ short_url }}">{{ short_url }}</a>
+    </div>
+
+    <div class="item">
+      <div class="label">Stats Page</div>
+      <a class="value" href="{{ stats_url }}">{{ stats_url }}</a>
+    </div>
+
+    <div class="item">
+      <div class="label">Destination URL</div>
+      <span class="value">{{ url }}</span>
+    </div>
+
+    <div class="actions">
+      <a class="btn" href="/">Shorten Another</a>
+      <a class="btn secondary" href="{{ short_url }}">Test Redirect</a>
+    </div>
+  </main>
+</body>
+</html>
diff --git a/examples/url_shortener.ry b/examples/url_shortener.ry
new file mode 100644
index 0000000..6f5d6a7
--- /dev/null
+++ b/examples/url_shortener.ry
@@ -0,0 +1,178 @@
+app = WebApp()
+server = WebServer()
+
+mongo = Mongo("mongodb://localhost:27017")
+db = mongo.db("shorty")
+urls = db.collection("urls")
+
+base_url = "http://127.0.0.1:8080"
+
+// Simple per-session abuse guard for write endpoints.
+fn rate_limit(ctx, next)
+    method = ctx["method"]
+    path = ctx["path"]
+    if (method == "POST" && (path == "/" || path == "/api/v1/shorten"))
+        count = session["shorten_count"]
+        if (count == None)
+            count = 0
+
+        if (count >= 50)
+            return HTTPError(429, "rate_limited", "Too many shorten requests in this session")
+
+        session["shorten_count"] = count + 1
+
+    return next()
+
+// CSRF protection for browser form posts; API route is exempt.
+fn csrf_protect(ctx, next)
+    method = ctx["method"]
+    path = ctx["path"]
+
+    if (method == "POST" && path == "/")
+        form = ctx["form"]
+        token = form["csrf_token"]
+        if (token == None || token != session["csrf_token"])
+            return HTTPError(403, "csrf_failed", "Invalid CSRF token")
+
+    return next()
+
+app.use(rate_limit)
+app.use(csrf_protect)
+
+fn is_http_url(url)
+    if (url == None)
+        return False
+
+    if (len(url) < 7)
+        return False
+
+    is_http = (
+        url[0] == "h" &&
+        url[1] == "t" &&
+        url[2] == "t" &&
+        url[3] == "p" &&
+        url[4] == ":" &&
+        url[5] == "/" &&
+        url[6] == "/"
+    )
+
+    if (is_http == True)
+        return True
+
+    if (len(url) < 8)
+        return False
+
+    return (
+        url[0] == "h" &&
+        url[1] == "t" &&
+        url[2] == "t" &&
+        url[3] == "p" &&
+        url[4] == "s" &&
+        url[5] == ":" &&
+        url[6] == "/" &&
+        url[7] == "/"
+    )
+
[email protected]("/")
+fn index()
+    if (session["csrf_token"] == None)
+        session["csrf_token"] = request["request_id"]
+
+    return app.render_template(
+        "examples/templates/url_shortener_index.html",
+        csrf_token=session["csrf_token"]
+    )
+
[email protected]("/")
+fn create_short(url = None)
+    if (is_http_url(url) == False)
+        return Response(
+            app.render_template("examples/templates/url_shortener_400.html"),
+            status=400,
+            content_type="text/html"
+        )
+
+    result = urls.insert_one({
+        "url": url,
+        "clicks": 0
+    })
+
+    short_id = str(result["inserted_id"])
+    short_url = base_url + "/" + short_id
+    stats_url = base_url + "/stats/" + short_id
+
+    return app.render_template(
+        "examples/templates/url_shortener_success.html",
+        short_url=short_url,
+        stats_url=stats_url,
+        url=url
+    )
+
[email protected]("/<short_id>")
+fn redirect_short(short_id)
+    doc = urls.find_one({"_id": short_id})
+    if (doc == None)
+        return Response(
+            app.render_template("examples/templates/url_shortener_404.html"),
+            status=404,
+            content_type="text/html"
+        )
+
+    urls.update_one({"_id": short_id}, {"$inc": {"clicks": 1}})
+    return app.redirect(doc["url"])
+
[email protected]("/stats/<short_id>")
+fn stats_page(short_id)
+    doc = urls.find_one({"_id": short_id})
+    if (doc == None)
+        return Response(
+            app.render_template("examples/templates/url_shortener_404.html"),
+            status=404,
+            content_type="text/html"
+        )
+
+    short_url = base_url + "/" + short_id
+    url = doc["url"]
+    clicks = doc["clicks"]
+
+    return app.render_template(
+        "examples/templates/url_shortener_stats.html",
+        short_url=short_url,
+        url=url,
+        clicks=clicks
+    )
+
[email protected]("/api/v1/shorten")
+fn api_shorten(url = None)
+    if (is_http_url(url) == False)
+        return HTTPError(400, "invalid_url", "URL must start with http:// or https://")
+
+    result = urls.insert_one({
+        "url": url,
+        "clicks": 0
+    })
+
+    short_id = str(result["inserted_id"])
+    return {
+        "status": "success",
+        "long_url": url,
+        "short_id": short_id,
+        "short_url": base_url + "/" + short_id,
+        "stats_url": base_url + "/api/v1/stats/" + short_id
+    }
+
[email protected]("/api/v1/stats/<short_id>")
+fn api_stats(short_id)
+    doc = urls.find_one({"_id": short_id})
+    if (doc == None)
+        return HTTPError(404, "not_found", "Short URL not found")
+
+    return {
+        "status": "success",
+        "short_id": short_id,
+        "short_url": base_url + "/" + short_id,
+        "long_url": doc["url"],
+        "clicks": doc["clicks"]
+    }
+
+server.run(app)