From dfe2069c1dd4eb14d44a57d35d76afdc47d1598e Mon Sep 17 00:00:00 2001 From: Morgana Date: Sat, 3 Oct 2026 10:25:51 -0500 Subject: Initial captcha code --- .gitignore | 3 +- app.py | 45 +++++++++++ captcha.py | 193 +++++++++++++++++++++++++++++++++++++++++++++++ static/script/captcha.js | 143 +++++++++++++++++++++++++++++++++++ static/style/captcha.css | 76 +++++++++++++++++++ templates/base.html | 6 ++ templates/guest.html | 13 ++++ 7 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 captcha.py create mode 100644 static/script/captcha.js create mode 100644 static/style/captcha.css create mode 100644 templates/guest.html 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 @@ +{% macro render_captcha(captcha) %} + +{% endmacro %} + Drosera Springs | {% block title %}Website{% endblock %} 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 %} + +{% endblock %} + +{% block content %} +
+

Guest Access

+

Please solve the following captcha to continue.

+ {{ render_captcha(captcha) }} +
+{% endblock %} \ No newline at end of file -- cgit v1.3.1