summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMorgana <Morgana@icolotl.com>2026-10-03 10:25:51 -0500
committerMorgana <Morgana@icolotl.com>2026-10-03 10:25:51 -0500
commitdfe2069c1dd4eb14d44a57d35d76afdc47d1598e (patch)
tree88d2a4688bbc536af1d90ce12118cbf028f9f60b
parente96fdc880d45ab22eea5eb1210fe767ce7fcd34e (diff)
Initial captcha code
-rw-r--r--.gitignore3
-rw-r--r--app.py45
-rw-r--r--captcha.py193
-rw-r--r--static/script/captcha.js143
-rw-r--r--static/style/captcha.css76
-rw-r--r--templates/base.html6
-rw-r--r--templates/guest.html13
7 files changed, 478 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore
index f468089..74757fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
__pycache__/
.venv/
akasha.db
-config.json \ No newline at end of file
+config.json
+captcha.key \ No newline at end of file
diff --git a/app.py b/app.py
index 7869de9..6a98763 100644
--- a/app.py
+++ b/app.py
@@ -16,9 +16,15 @@ from flask import (
)
from werkzeug.exceptions import HTTPException
+from captcha import Captcha
+
SCRYPT_PARAMS = {"n": 16384, "r": 8, "p": 1}
HEX_CHARS = set("0123456789ABCDEF")
+GUEST_CAPTCHA = Captcha.from_string(
+ 5, "m...cena....m...ebneb.clm", ".........................", True
+)
+
def get_db():
if "db" not in g:
@@ -32,6 +38,16 @@ def teardown(e=None):
db.close()
+def has_captcha(captcha_id):
+ return captcha_id in session.get("captcha", [])
+
+
+def add_captcha(captcha_id):
+ if "captcha" not in session:
+ session["captcha"] = []
+ session["captcha"].append(captcha_id)
+
+
app = Flask(__name__)
with open("./config.json", "r", encoding="utf-8") as file:
config = json.load(file)
@@ -41,6 +57,11 @@ with open("./config.json", "r", encoding="utf-8") as file:
app.teardown_appcontext(teardown)
+@app.context_processor
+def context_processor():
+ return dict(has_captcha=has_captcha)
+
+
@app.errorhandler(HTTPException)
def handle_bad_request(e):
if request.endpoint != "static":
@@ -104,10 +125,21 @@ def login():
return render_template("login.html")
+@app.route("/guest")
+def guest_login():
+ if has_captcha(GUEST_CAPTCHA.id) and "username" not in session:
+ session["username"] = "guest"
+ flash(f"Success: Logged in as guest.")
+ return redirect(url_for("index"))
+ return render_template("guest.html", captcha=GUEST_CAPTCHA)
+
+
@app.route("/logout")
def logout():
if "username" in session:
del session["username"]
+ if "captcha" in session:
+ del session["captcha"]
flash("Success: You have been logged out.")
return redirect(url_for("index"))
@@ -133,3 +165,16 @@ def news():
cur.execute("SELECT * FROM news ORDER BY id DESC")
results = cur.fetchall()
return render_template("news.html", news=results)
+
+
+@app.route("/captcha")
+def captcha():
+ size = request.args.get("size", "")
+ label = request.args.get("label", "")
+ lock = request.args.get("lock", "")
+ answer = request.args.get("answer", "")
+ captcha = Captcha.safe_validate(size, label, lock, answer)
+ if captcha:
+ add_captcha(captcha.id)
+ return {"valid": True}, 200
+ return {"valid": False}, 400
diff --git a/captcha.py b/captcha.py
new file mode 100644
index 0000000..e5fe28c
--- /dev/null
+++ b/captcha.py
@@ -0,0 +1,193 @@
+from typing import Self
+from hashlib import md5
+
+ADJ = [(x, y) for x in range(-1, 2) for y in range(-1, 2) if x != 0 or y != 0]
+ORTH = [dir for dir in ADJ if dir[0] == 0 or dir[1] == 0]
+VALID_LABEL = set("abcdefghijklmnopqrstuv.")
+VALID_LOCK = set("0123.")
+VALID_ANSWER = set("0123")
+
+
+def value(grid, x: int, y: int) -> int | None:
+ side_length = int(len(grid) ** 0.5)
+ if not 0 <= x < side_length:
+ return None
+ if not 0 <= y < side_length:
+ return None
+ return grid[y * side_length + x]
+
+
+def adj(grid, x: int, y: int, orth=False, center=False) -> list[int]:
+ base = [value(grid, x + dx, y + dy) for dx, dy in (ORTH if orth else ADJ)]
+ if center:
+ base.append(value(grid, x, y))
+ return [v for v in base if v is not None]
+
+
+class Shape:
+ def __init__(self, grid, x: int, y: int) -> None:
+ self.value = value(grid, x, y)
+ proc = [(x, y)]
+ seen = set(proc)
+ while proc:
+ x, y = proc.pop()
+ for dx, dy in ORTH:
+ possible = (x + dx, y + dy)
+ if value(grid, possible[0], possible[1]) == self.value:
+ if possible not in seen:
+ seen.add(possible)
+ proc.append(possible)
+ self.size = len(seen)
+ left = min([pt[0] for pt in seen])
+ right = max([pt[0] for pt in seen])
+ top = min([pt[1] for pt in seen])
+ bottom = max([pt[1] for pt in seen])
+ output = []
+ for y in range(top, bottom + 1):
+ row = []
+ for x in range(left, right + 1):
+ row.append("X" if (x, y) in seen else ".")
+ output.append("".join(row))
+ self.shape = "\n".join(output)
+ self.points = seen
+
+
+class Captcha:
+
+ def __init__(self, size: int) -> None:
+ tile_count = size**2
+ self.size = size
+ self.label: list[str | None] = [None] * tile_count
+ self.lock: list[int | None] = [None] * tile_count
+ self.reload = False
+
+ def __members(self) -> tuple[int, list[str | None], list[int | None]]:
+ return (self.size, self.label, self.lock)
+
+ def __eq__(self, other) -> bool:
+ if not isinstance(other, Captcha):
+ return False
+ return self.__members() == other.__members()
+
+ def __hash__(self) -> int:
+ return hash(self.__members())
+
+ @classmethod
+ def from_string(
+ cls, size: int, label: str, lock: str, reload: bool = False
+ ) -> Self:
+ result = cls(size)
+ result.label = [char if char != "." else None for char in label]
+ result.lock = [int(char) if char != "." else None for char in lock]
+ result.reload = reload
+ result.recalc_id()
+ return result
+
+ def make_label(self) -> str:
+ return "".join(char if char else "." for char in self.label)
+
+ def make_lock(self) -> str:
+ return "".join(str(char) if char is not None else "." for char in self.lock)
+
+ def recalc_id(self) -> None:
+ self.id = md5(
+ (self.make_label() + self.make_lock()).encode("utf-8")
+ ).hexdigest()
+
+ def validate(self, answer_string: str) -> bool:
+
+ # Ensure answer is of right length
+ answer = [int(char) for char in answer_string]
+ if len(answer) != len(self.label):
+ return False
+
+ # Ensure lock is correct
+ for ans, lk in zip(answer, self.lock):
+ if lk is not None:
+ if ans != lk:
+ return False
+
+ # Ensure label is correct
+ color_match = None
+ shape_match_m = None
+ shape_match_n = None
+ for x in range(0, self.size):
+ for y in range(0, self.size):
+ match value(self.label, x, y):
+ case "a":
+ if Shape(answer, x, y).size != 1:
+ return False
+ case "b":
+ if Shape(answer, x, y).size != 2:
+ return False
+ case "c":
+ if Shape(answer, x, y).size != 3:
+ return False
+ case "d":
+ if Shape(answer, x, y).size != 4:
+ return False
+ case "e":
+ near = adj(answer, x, y, True, False)
+ if len(near) != len(set(near)):
+ return False
+ case "m":
+ shape = Shape(answer, x, y)
+ if shape_match_m:
+ if shape_match_m != shape.shape:
+ return False
+ else:
+ shape_match_m = shape.shape
+ if shape_match_m == shape_match_n:
+ return False
+ case "n":
+ shape = Shape(answer, x, y)
+ if shape_match_n:
+ if shape_match_n != shape.shape:
+ return False
+ else:
+ shape_match_n = shape.shape
+ if shape_match_m == shape_match_n:
+ return False
+ case "r":
+ color = value(answer, x, y)
+ if color_match:
+ if color_match != color:
+ return False
+ else:
+ color_match = color
+ case "v":
+ if len(set(adj(answer, x, y, True, True))) != 1:
+ return False
+ case _:
+ pass
+ return True
+
+ @classmethod
+ def safe_validate(
+ cls, size: str, label: str, lock: str, answer: str
+ ) -> Captcha | None:
+ try:
+ int_size = int(size)
+ except ValueError:
+ return None
+ tile_count = int_size**2
+ if (
+ len(label) != tile_count
+ or len(lock) != tile_count
+ or len(answer) != tile_count
+ or any(char not in VALID_LABEL for char in label)
+ or any(char not in VALID_LOCK for char in lock)
+ or any(char not in VALID_ANSWER for char in answer)
+ ):
+ return None
+ captcha = Captcha.from_string(int_size, label, lock)
+ if captcha.validate(answer):
+ return captcha
+ return None
+
+
+if __name__ == "__main__":
+ test = Captcha.from_string(
+ 5, "m...cena....m...ebneb.clm", "........................."
+ )
+ print(test.validate("0111001010203101231212212"))
diff --git a/static/script/captcha.js b/static/script/captcha.js
new file mode 100644
index 0000000..78dcb3d
--- /dev/null
+++ b/static/script/captcha.js
@@ -0,0 +1,143 @@
+textXform = {
+ "a": "\u{10900}",
+ "b": "\u{10901}",
+ "c": "\u{10902}",
+ "d": "\u{10903}",
+ "e": "\u{10904}",
+ "f": "\u{10905}",
+ "g": "\u{10906}",
+ "h": "\u{10907}",
+ "i": "\u{10908}",
+ "j": "\u{10909}",
+ "k": "\u{1090A}",
+ "l": "\u{1090B}",
+ "m": "\u{1090C}",
+ "n": "\u{1090D}",
+ "o": "\u{1090E}",
+ "p": "\u{1090F}",
+ "q": "\u{10910}",
+ "r": "\u{10911}",
+ "s": "\u{10912}",
+ "t": "\u{10913}",
+ "u": "\u{10914}",
+ "v": "\u{10915}",
+ ".": "\u{1091F}",
+}
+
+class DroseraCaptcha extends HTMLElement {
+ constructor() {
+ super();
+ }
+ onSquareClick(event) {
+ const square = event.target;
+ if (square.dataset.active === undefined) {
+ square.dataset.active = "1";
+ } else if (square.dataset.active == "1") {
+ square.dataset.active = "2";
+ } else if (square.dataset.active == "2") {
+ square.dataset.active = "3";
+ } else {
+ delete square.dataset.active;
+ }
+ event.preventDefault();
+ }
+ onSquareReset(event) {
+ const square = event.target;
+ delete square.dataset.active;
+ event.preventDefault();
+ }
+ onSquareContext(event) {
+ event.preventDefault();
+ }
+ onResetCaptcha(event) {
+ const grid = event.target.parentNode.getElementsByClassName("dsc-inner")[0];
+ for (const child of grid.childNodes) {
+ if (!child.disabled) {
+ delete child.dataset.active;
+ }
+ }
+ }
+ onSubmitCaptcha(event) {
+ const grid = event.target.parentNode.getElementsByClassName("dsc-inner")[0];
+ const output = grid.childNodes.values().map(square => square.dataset.active ?? "0").join("");
+ const params = new URLSearchParams({
+ size: this.size,
+ label: this.label,
+ lock: this.lock,
+ answer: output
+ })
+ console.log("/captcha?" + params.toString())
+ const request = new Request("/captcha?" + params.toString());
+ this.outer.disabled = true;
+ window.fetch(request).then(response => {
+ if (response.ok) {
+ this.outer.disabled = false;
+ this.outer.dataset.solved = "";
+ if (this.reload) {
+ location.reload();
+ }
+ } else {
+ this.outer.disabled = false;
+ }
+ })
+ }
+ connectedCallback() {
+ const shadow = this.attachShadow({ mode: "open" });
+
+ const linkElem = document.createElement("link");
+ linkElem.setAttribute("rel", "stylesheet");
+ linkElem.setAttribute("href", "/static/style/captcha.css");
+ shadow.appendChild(linkElem);
+
+ const outer = document.createElement("fieldset");
+ outer.className = "dsc-outer";
+ if (this.dataset.solved !== undefined) {
+ outer.dataset.solved = "";
+ }
+ this.reload = this.dataset.reload !== undefined;
+
+ const resetBtn = document.createElement("button");
+ resetBtn.innerText = "Reset"
+ resetBtn.addEventListener("click", this.onResetCaptcha);
+ const submitBtn = document.createElement("button");
+ submitBtn.className = "dsc-submit";
+ submitBtn.innerText = "Submit"
+ submitBtn.addEventListener("click", this.onSubmitCaptcha.bind(this));
+
+ const inner = document.createElement("div");
+ inner.style.gridTemplateColumns = `repeat(${this.dataset.size}, 1fr)`
+ inner.className = "dsc-inner";
+ for (var alp = 0; alp < this.dataset.size; alp++) {
+ for (var bet = 0; bet < this.dataset.size; bet++) {
+ const square = document.createElement("button");
+ square.tabIndex = 0;
+ square.addEventListener("click", this.onSquareClick);
+ square.addEventListener("auxclick", this.onSquareReset);
+ square.addEventListener("contextmenu", this.onSquareContext);
+ const label = document.createElement("span");
+ const shift = alp * this.dataset.size + bet;
+ label.innerText = textXform[this.dataset.label.at(shift)];
+ const lock = this.dataset.lock.at(shift);
+ if (lock != ".") {
+ square.dataset.active = lock;
+ square.disabled = true;
+ }
+ square.appendChild(label);
+ inner.appendChild(square);
+ }
+ }
+
+ this.size = this.dataset.size;
+ this.label = this.dataset.label;
+ this.lock = this.dataset.lock;
+ this.outer = outer;
+ this.inner = inner;
+
+ outer.appendChild(resetBtn);
+ outer.appendChild(inner);
+ outer.appendChild(submitBtn);
+ shadow.appendChild(outer);
+ }
+}
+
+customElements.define("drosera-captcha", DroseraCaptcha); \ No newline at end of file
diff --git a/static/style/captcha.css b/static/style/captcha.css
new file mode 100644
index 0000000..fe7c5d3
--- /dev/null
+++ b/static/style/captcha.css
@@ -0,0 +1,76 @@
+.dsc-outer {
+ --active-1: oklch(75% 0.15 195.032);
+ --active-2: oklch(75% 0.15 328.437);
+ --active-3: oklch(75% 0.15 109.374);
+ transform: rotate(-45deg) scale(0.7);
+ width: fit-content;
+ height: fit-content;
+ margin: auto;
+ display: grid;
+ gap: 0.5em;
+ grid-template-columns: auto auto;
+ padding: 0.5em;
+ border: none;
+
+ &[data-solved] {
+ outline: 2px solid var(--active-1);
+ }
+
+ >button {
+ font-size: 1.5em;
+
+ &.dsc-submit {
+ writing-mode: sideways-rl;
+ }
+ }
+}
+
+button {
+ border: 2px outset #000;
+ background-color: #eee;
+
+ &:active {
+ border: 2px inset #000;
+ }
+}
+
+.dsc-inner {
+ grid-column-start: 1;
+ border: 2px inset #000;
+ display: grid;
+ gap: 0.5em;
+ padding: 0.5em;
+
+ >button {
+ height: 2em;
+ width: 2em;
+ aspect-ratio: 1 / 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 2em;
+
+ >span {
+ transform: rotate(45deg);
+ pointer-events: none;
+ }
+
+ &:disabled {
+ border: 2px solid #000;
+ outline: 1px solid #000;
+ color: #000;
+ }
+
+ &[data-active="1"] {
+ background-color: var(--active-1);
+ }
+
+ &[data-active="2"] {
+ background-color: var(--active-2);
+ }
+
+ &[data-active="3"] {
+ background-color: var(--active-3);
+ }
+ }
+} \ No newline at end of file
diff --git a/templates/base.html b/templates/base.html
index df1b899..caf8da7 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1,6 +1,12 @@
<!DOCTYPE html>
<html lang="en">
+{% macro render_captcha(captcha) %}
+<drosera-captcha data-size="{{ captcha.size }}" data-label="{{ captcha.make_label() }}"
+ data-lock="{{ captcha.make_lock() }}" {% if captcha.reload %}data-reload{% endif %} {% if has_captcha(captcha.id)
+ %}data-solved{% endif %}></drosera-captcha>
+{% endmacro %}
+
<head>
<title>Drosera Springs | {% block title %}Website{% endblock %}</title>
<link rel="preload" href="/static/img/bgcropped.webp" as="image" fetchpriority="high" />
diff --git a/templates/guest.html b/templates/guest.html
new file mode 100644
index 0000000..8399554
--- /dev/null
+++ b/templates/guest.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block head %}
+<script src="{{ url_for('static', filename='script/captcha.js') }}" defer></script>
+{% endblock %}
+
+{% block content %}
+<main class="centered">
+ <h2>Guest Access</h2>
+ <p>Please solve the following captcha to continue.</p>
+ {{ render_captcha(captcha) }}
+</main>
+{% endblock %} \ No newline at end of file