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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5"""Simple mathematical captcha.""" 

6 

7from __future__ import annotations 

8 

9import ast 

10import base64 

11import json 

12import operator 

13import time 

14import urllib.parse 

15from random import SystemRandom 

16from typing import TYPE_CHECKING, ClassVar 

17 

18from altcha import solve_challenge 

19from django.utils.html import format_html 

20 

21from weblate.utils.templatetags.icons import icon 

22 

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 

25 

26TIMEDELTA = 600 

27 

28# Supported operators 

29OPERATORS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul} 

30 

31 

32class MathCaptcha: 

33 """Simple match captcha object.""" 

34 

35 operators = ("+", "-", "*") 

36 operators_display: ClassVar[dict[str, str]] = {} 

37 interval = (1, 10) 

38 

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 } 

54 

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]) 

61 

62 # We don't want negative answers 

63 if operation == "-": 

64 first += self.interval[1] 

65 

66 return str(first) + " " + operation + " " + str(second) 

67 

68 @staticmethod 

69 def unserialize(value): 

70 """Create object from serialized.""" 

71 return MathCaptcha(*value) 

72 

73 def serialize(self): 

74 """Serialize captcha settings.""" 

75 return (self.question, self.timestamp) 

76 

77 def validate(self, answer): 

78 """Validate answer.""" 

79 return self.result == answer and self.timestamp + TIMEDELTA > time.time() 

80 

81 @property 

82 def result(self): 

83 """Return result.""" 

84 return eval_expr(self.question) 

85 

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 ) 

93 

94 

95def eval_expr(expr): 

96 """ 

97 Evaluate arithmetic expression used in Captcha. 

98 

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) 

105 

106 

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) 

119 

120 

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")