Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/captcha.py: 38%
69 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5"""Simple mathematical captcha."""
7from __future__ import annotations
9import ast
10import base64
11import json
12import operator
13import time
14import urllib.parse
15from random import SystemRandom
16from typing import TYPE_CHECKING, ClassVar
18from altcha import solve_challenge
19from django.utils.html import format_html
21from weblate.utils.templatetags.icons import icon
23if TYPE_CHECKING: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 from altcha import Challenge, Solution
26TIMEDELTA = 600
28# Supported operators
29OPERATORS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul}
32class MathCaptcha:
33 """Simple match captcha object."""
35 operators = ("+", "-", "*")
36 operators_display: ClassVar[dict[str, str]] = {}
37 interval = (1, 10)
39 def __init__(self, question=None, timestamp=None) -> None:
40 if question is None:
41 self.question = self.generate_question()
42 else:
43 self.question = question
44 if timestamp is None:
45 self.timestamp = time.time()
46 else:
47 self.timestamp = timestamp
48 if not self.operators_display:
49 self.operators_display = {
50 "+": icon("plus.svg"),
51 "-": icon("minus.svg"),
52 "*": icon("close.svg"),
53 }
55 def generate_question(self):
56 """Generate random question."""
57 generator = SystemRandom()
58 operation = generator.choice(self.operators)
59 first = generator.randint(self.interval[0], self.interval[1])
60 second = generator.randint(self.interval[0], self.interval[1])
62 # We don't want negative answers
63 if operation == "-":
64 first += self.interval[1]
66 return str(first) + " " + operation + " " + str(second)
68 @staticmethod
69 def unserialize(value):
70 """Create object from serialized."""
71 return MathCaptcha(*value)
73 def serialize(self):
74 """Serialize captcha settings."""
75 return (self.question, self.timestamp)
77 def validate(self, answer):
78 """Validate answer."""
79 return self.result == answer and self.timestamp + TIMEDELTA > time.time()
81 @property
82 def result(self):
83 """Return result."""
84 return eval_expr(self.question)
86 @property
87 def display(self):
88 """Get unicode for display."""
89 parts = self.question.split()
90 return format_html(
91 "{} {} {}", parts[0], self.operators_display[parts[1]], parts[2]
92 )
95def eval_expr(expr):
96 """
97 Evaluate arithmetic expression used in Captcha.
99 >>> eval_expr("2+6")
100 8
101 >>> eval_expr("2*6")
102 12
103 """
104 return eval_node(ast.parse(expr).body[0].value)
107def eval_node(node):
108 """Evaluate single AST node."""
109 if isinstance(node, ast.Constant):
110 # number
111 return node.value
112 if isinstance(node, ast.operator):
113 # operator
114 return OPERATORS[type(node)]
115 if isinstance(node, ast.BinOp):
116 # binary operation
117 return eval_node(node.op)(eval_node(node.left), eval_node(node.right))
118 raise ValueError(node)
121def solve_altcha(challenge: Challenge, number: int | None = None) -> str:
122 solution: Solution = solve_challenge(
123 challenge=challenge.challenge,
124 salt=challenge.salt,
125 algorithm=challenge.algorithm,
126 max_number=challenge.max_number,
127 start=0,
128 )
129 # Make sure the challenge expiry is in past
130 split_salt = challenge.salt.split("?")
131 params = urllib.parse.parse_qs(split_salt[1])
132 expires = int(params["expires"][0])
133 while time.time() == expires:
134 time.sleep(0.1)
135 return base64.b64encode(
136 json.dumps(
137 {
138 "algorithm": challenge.algorithm,
139 "challenge": challenge.challenge,
140 "number": solution.number if number is None else number,
141 "salt": challenge.salt,
142 "signature": challenge.signature,
143 }
144 ).encode("utf-8")
145 ).decode("utf-8")