ethiopian-captcha 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/package.json +23 -0
- package/src/config/config.js +6 -0
- package/src/constants/amharicLetters.js +35 -0
- package/src/core/captchaEngine.js +79 -0
- package/src/index.js +2 -0
- package/src/react/EtCaptcha.jsx +68 -0
- package/src/utils/hash.js +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# EthiopianCaptcha 🇪🇹
|
|
2
|
+
|
|
3
|
+
A lightweight, secure, and extensible CAPTCHA library designed for Ethiopian languages (starting with Amharic Fidel).
|
|
4
|
+
|
|
5
|
+
EthiopianCaptcha provides:
|
|
6
|
+
|
|
7
|
+
- Server-side CAPTCHA generation & validation
|
|
8
|
+
- Secure hashed answers (no plaintext storage)
|
|
9
|
+
- TTL & attempt limiting
|
|
10
|
+
- Optional React UI component
|
|
11
|
+
- Zero external dependencies
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- 🇪🇹 Amharic (Fidel) CAPTCHA support
|
|
18
|
+
- Hashed answers (security-first)
|
|
19
|
+
- Configurable expiration (TTL)
|
|
20
|
+
- Attempt limiting
|
|
21
|
+
- Optional React component
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install ethiopian-captcha
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Server-side Usage (Node.js)
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { generateCaptcha, validateCaptcha } from "ethiopian-captcha";
|
|
37
|
+
|
|
38
|
+
// Generate captcha
|
|
39
|
+
const captchaData = generateCaptcha();
|
|
40
|
+
|
|
41
|
+
console.log(captchaData);
|
|
42
|
+
/*
|
|
43
|
+
{
|
|
44
|
+
success: true,
|
|
45
|
+
type: "fidel",
|
|
46
|
+
captchaId: "ETH-...",
|
|
47
|
+
captcha: ["ኩ", "ቴ", "ኃ", "ሀ"]
|
|
48
|
+
}
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Validate captcha
|
|
52
|
+
const result = validateCaptcha(captchaData.captchaId, "ኩቴኃሀ");
|
|
53
|
+
|
|
54
|
+
if (result.success) {
|
|
55
|
+
console.log("Captcha verified");
|
|
56
|
+
} else {
|
|
57
|
+
console.log(result.message);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## React Component (Optional)
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
import { EtCaptcha } from "ethiopian-captcha/react";
|
|
67
|
+
|
|
68
|
+
<EtCaptcha
|
|
69
|
+
captcha={captcha}
|
|
70
|
+
captchaId={captchaId}
|
|
71
|
+
loading={loading}
|
|
72
|
+
onRefresh={fetchNewCaptcha}
|
|
73
|
+
onVerify={({ captchaId, input }) => {
|
|
74
|
+
validateCaptcha({ captchaId, input });
|
|
75
|
+
}}
|
|
76
|
+
/>;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The React component:
|
|
80
|
+
|
|
81
|
+
- Does NOT handle validation logic
|
|
82
|
+
- Does NOT make HTTP requests
|
|
83
|
+
- Is fully controlled by props
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
export const captchaConfig = {
|
|
91
|
+
type: "fidel",
|
|
92
|
+
count: 4,
|
|
93
|
+
ttl: 2 * 60 * 1000,
|
|
94
|
+
attempts: 3,
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Security Notes
|
|
101
|
+
|
|
102
|
+
- Answers are stored as hashes
|
|
103
|
+
- Captchas auto-expire
|
|
104
|
+
- Entries are removed after success or failure
|
|
105
|
+
- In-memory storage (stateless & fast)
|
|
106
|
+
|
|
107
|
+
⚠️ For distributed systems, use Redis or a shared store.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Roadmap
|
|
112
|
+
|
|
113
|
+
- Numeric CAPTCHA
|
|
114
|
+
- Mixed-type CAPTCHA
|
|
115
|
+
- Redis adapter
|
|
116
|
+
- TypeScript support
|
|
117
|
+
- Canvas-based CAPTCHA rendering
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT © EthiopianCaptcha
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ethiopian-captcha",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./react": "./src/react/EtCaptcha.jsx"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"captcha",
|
|
12
|
+
"ethiopia",
|
|
13
|
+
"amharic",
|
|
14
|
+
"fidel",
|
|
15
|
+
"security"
|
|
16
|
+
],
|
|
17
|
+
"author": "Amenadam Solomon",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/amenadam/ethiopian-captcha"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const AMHARIC_LETTERS = [
|
|
2
|
+
'ሀ','ሁ','ሂ','ሃ','ሄ','ህ','ሆ',
|
|
3
|
+
'ለ','ሉ','ሊ','ላ','ሌ','ል','ሎ',
|
|
4
|
+
'ሐ','ሑ','ሒ','ሓ','ሔ','ሕ','ሖ',
|
|
5
|
+
'መ','ሙ','ሚ','ማ','ሜ','ም','ሞ',
|
|
6
|
+
'ሠ','ሡ','ሢ','ሣ','ሤ','ሥ','ሦ',
|
|
7
|
+
'ረ','ሩ','ሪ','ራ','ሬ','ር','ሮ',
|
|
8
|
+
'ሰ','ሱ','ሲ','ሳ','ሴ','ስ','ሶ',
|
|
9
|
+
'ሸ','ሹ','ሺ','ሻ','ሼ','ሽ','ሾ',
|
|
10
|
+
'ቀ','ቁ','ቂ','ቃ','ቄ','ቅ','ቆ',
|
|
11
|
+
'በ','ቡ','ቢ','ባ','ቤ','ብ','ቦ',
|
|
12
|
+
'ተ','ቱ','ቲ','ታ','ቴ','ት','ቶ',
|
|
13
|
+
'ቸ','ቹ','ቺ','ቻ','ቼ','ች','ቾ',
|
|
14
|
+
'ኀ','ኁ','ኂ','ኃ','ኄ','ኅ','ኆ',
|
|
15
|
+
'ነ','ኑ','ኒ','ና','ኔ','ን','ኖ',
|
|
16
|
+
'ኘ','ኙ','ኚ','ኛ','ኜ','ኝ','ኞ',
|
|
17
|
+
'አ','ኡ','ኢ','ኣ','ኤ','እ','ኦ',
|
|
18
|
+
'ከ','ኩ','ኪ','ካ','ኬ','ክ','ኮ',
|
|
19
|
+
'ኸ','ኹ','ኺ','ኻ','ኼ','ኽ','ኾ',
|
|
20
|
+
'ወ','ዉ','ዊ','ዋ','ዌ','ው','ዎ',
|
|
21
|
+
'ዐ','ዑ','ዒ','ዓ','ዔ','ዕ','ዖ',
|
|
22
|
+
'ዘ','ዙ','ዚ','ዛ','ዜ','ዝ','ዞ',
|
|
23
|
+
'ዠ','ዡ','ዢ','ዣ','ዤ','ዥ','ዦ',
|
|
24
|
+
'የ','ዩ','ዪ','ያ','ዬ','ይ','ዮ',
|
|
25
|
+
'ደ','ዱ','ዲ','ዳ','ዴ','ድ','ዶ',
|
|
26
|
+
'ጀ','ጁ','ጂ','ጃ','ጄ','ጅ','ጆ',
|
|
27
|
+
'ገ','ጉ','ጊ','ጋ','ጌ','ግ','ጎ',
|
|
28
|
+
'ጠ','ጡ','ጢ','ጣ','ጤ','ጥ','ጦ',
|
|
29
|
+
'ጨ','ጩ','ጪ','ጫ','ጬ','ጭ','ጮ',
|
|
30
|
+
'ጰ','ጱ','ጲ','ጳ','ጴ','ጵ','ጶ',
|
|
31
|
+
'ጸ','ጹ','ጺ','ጻ','ጼ','ጽ','ጾ',
|
|
32
|
+
'ፀ','ፁ','ፂ','ፃ','ፄ','ፅ','ፆ',
|
|
33
|
+
'ፈ','ፉ','ፊ','ፋ','ፌ','ፍ','ፎ',
|
|
34
|
+
'ፐ','ፑ','ፒ','ፓ','ፔ','ፕ','ፖ'
|
|
35
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AMHARIC_LETTERS } from "../constants/amharicLetters.js";
|
|
2
|
+
import { hashAnswer } from "../utils/hash.js";
|
|
3
|
+
import { captchaConfig } from "../config/config.js";
|
|
4
|
+
|
|
5
|
+
const getRandomLetter = () => {
|
|
6
|
+
return AMHARIC_LETTERS[Math.floor(Math.random() * AMHARIC_LETTERS.length)];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const getRandomLetters = (count = 4) => {
|
|
10
|
+
let last = "";
|
|
11
|
+
let randomLetters = [];
|
|
12
|
+
for (let i = 0; i < count; i++) {
|
|
13
|
+
let letter;
|
|
14
|
+
do {
|
|
15
|
+
letter = getRandomLetter();
|
|
16
|
+
} while (letter === last);
|
|
17
|
+
|
|
18
|
+
last = letter;
|
|
19
|
+
randomLetters.push(letter);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return randomLetters;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const generatedCaptchas = new Map();
|
|
26
|
+
|
|
27
|
+
export const generateCaptcha = (
|
|
28
|
+
type = captchaConfig.type,
|
|
29
|
+
count = captchaConfig.count
|
|
30
|
+
) => {
|
|
31
|
+
if (type !== "fidel") {
|
|
32
|
+
return { success: false, message: "Unsupported captcha type" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const captcha = getRandomLetters(count);
|
|
36
|
+
const captchaId = `ETH-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
37
|
+
|
|
38
|
+
generatedCaptchas.set(captchaId, {
|
|
39
|
+
answerHash: hashAnswer(captcha.join("")),
|
|
40
|
+
generatedAt: Date.now(),
|
|
41
|
+
attempts: 0,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
type,
|
|
47
|
+
captchaId,
|
|
48
|
+
captcha,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const CAPTCHA_TTL = captchaConfig.ttl;
|
|
53
|
+
const MAX_ATTEMPTS = captchaConfig.attempts;
|
|
54
|
+
export const validateCaptcha = (captchaId, userInput) => {
|
|
55
|
+
const captcha = generatedCaptchas.get(captchaId);
|
|
56
|
+
|
|
57
|
+
if (!captcha) {
|
|
58
|
+
return { success: false, message: "Invalid Captcha ID" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Date.now() - captcha.generatedAt > CAPTCHA_TTL) {
|
|
62
|
+
generatedCaptchas.delete(captchaId);
|
|
63
|
+
return { success: false, message: "Captcha expired" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
captcha.attempts++;
|
|
67
|
+
if (captcha.attempts > MAX_ATTEMPTS) {
|
|
68
|
+
generatedCaptchas.delete(captchaId);
|
|
69
|
+
return { success: false, message: "Too many attempts" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const inputHash = hashAnswer(userInput.trim());
|
|
73
|
+
if (inputHash === captcha.answerHash) {
|
|
74
|
+
generatedCaptchas.delete(captchaId);
|
|
75
|
+
return { success: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { success: false, message: "Incorrect captcha" };
|
|
79
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function EtCaptcha({
|
|
4
|
+
captcha = [],
|
|
5
|
+
captchaId,
|
|
6
|
+
onVerify,
|
|
7
|
+
onRefresh,
|
|
8
|
+
loading = false,
|
|
9
|
+
}) {
|
|
10
|
+
const [input, setInput] = useState("");
|
|
11
|
+
|
|
12
|
+
const handleVerify = () => {
|
|
13
|
+
if (!input.trim()) return;
|
|
14
|
+
|
|
15
|
+
onVerify({
|
|
16
|
+
captchaId,
|
|
17
|
+
input: input.trim(),
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
style={{
|
|
24
|
+
border: "1px solid #ddd",
|
|
25
|
+
borderRadius: 8,
|
|
26
|
+
padding: 16,
|
|
27
|
+
width: 260,
|
|
28
|
+
fontFamily: "system-ui, sans-serif",
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
style={{
|
|
33
|
+
fontSize: 24,
|
|
34
|
+
letterSpacing: 8,
|
|
35
|
+
textAlign: "center",
|
|
36
|
+
marginBottom: 12,
|
|
37
|
+
userSelect: "none",
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{captcha.join(" ")}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<input
|
|
44
|
+
type="text"
|
|
45
|
+
value={input}
|
|
46
|
+
onChange={(e) => setInput(e.target.value)}
|
|
47
|
+
placeholder="Type the letters"
|
|
48
|
+
disabled={loading}
|
|
49
|
+
style={{
|
|
50
|
+
width: "100%",
|
|
51
|
+
padding: 8,
|
|
52
|
+
fontSize: 14,
|
|
53
|
+
marginBottom: 10,
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
58
|
+
<button onClick={handleVerify} disabled={loading} style={{ flex: 1 }}>
|
|
59
|
+
Verify
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
<button onClick={onRefresh} disabled={loading}>
|
|
63
|
+
↻
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|