hacker-lobby 1.0.1 → 1.1.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 ADDED
@@ -0,0 +1,99 @@
1
+ # 📟 Hacker Lobby
2
+
3
+ A secure, real-time, multiplayer terminal chat application built with a zero-dependency Node.js CLI frontend and a Cloudflare Workers/D1 database serverless backend.
4
+
5
+ ---
6
+
7
+ ## 🚀 Getting Started with Node.js, npm, and npx
8
+
9
+ To run the Hacker Lobby client and server, you need **Node.js** installed on your system. Node.js comes bundled with **npm** (Node Package Manager) and **npx** (Node Package Runner) automatically.
10
+
11
+ ### 📥 1. Installation Guide
12
+
13
+ #### 🪟 Windows
14
+ * **Direct Installer**: Download the recommended LTS installer from the [official Node.js website](https://nodejs.org/). Run the `.msi` file and follow the default prompts.
15
+ * **Terminal (Winget)**: Open PowerShell or Command Prompt as administrator and run:
16
+ ```powershell
17
+ winget install OpenJS.NodeJS
18
+ ```
19
+
20
+ #### 🍎 macOS
21
+ * **Direct Installer**: Download the macOS installer (`.pkg`) from the [official Node.js website](https://nodejs.org/) and run it.
22
+ * **Homebrew**: Open Terminal and run:
23
+ ```bash
24
+ brew install node
25
+ ```
26
+
27
+ #### 🐧 Linux (Ubuntu/Debian)
28
+ Open Terminal and run the following command to install Node.js and npm:
29
+ ```bash
30
+ sudo apt update
31
+ sudo apt install nodejs npm -y
32
+ ```
33
+
34
+ ---
35
+
36
+ ### 💻 2. Accessing and Verifying via Terminal
37
+
38
+ Once installed, restart your terminal application (PowerShell, Command Prompt, or bash) and verify the installation:
39
+
40
+ 1. **Verify Node.js** (executes JavaScript code):
41
+ ```bash
42
+ node -v
43
+ ```
44
+ 2. **Verify npm** (installs and manages dependencies):
45
+ ```bash
46
+ npm -v
47
+ ```
48
+ 3. **Verify npx** (executes npm packages without globally installing them):
49
+ ```bash
50
+ npx -v
51
+ ```
52
+
53
+ ---
54
+
55
+ ## 🛠️ Project Setup
56
+
57
+ Follow these steps to set up and run Hacker Lobby locally:
58
+
59
+ ### 1. Install Dependencies
60
+ Clone the repository, navigate to the folder, and run:
61
+ ```bash
62
+ npm install
63
+ ```
64
+
65
+ ### 2. Configure Backend Database
66
+ Initialize the local Cloudflare D1 database and apply the SQL schema:
67
+ ```bash
68
+ npx wrangler d1 execute chat-db --local --file=schema.sql
69
+ ```
70
+
71
+ ### 3. Run the Backend Server
72
+ Start the local serverless backend with Wrangler:
73
+ ```bash
74
+ npx wrangler dev
75
+ ```
76
+ The server will start listening at `http://127.0.0.1:8787`.
77
+
78
+ ### 4. Connect with CLI Chat Client
79
+ Configure the client to connect to your local backend server using environment variables:
80
+
81
+ * **PowerShell (Windows)**:
82
+ ```powershell
83
+ $env:API_URL="http://127.0.0.1:8787"; node index.js
84
+ ```
85
+ * **macOS / Linux / Git Bash**:
86
+ ```bash
87
+ API_URL="http://127.0.0.1:8787" node index.js
88
+ ```
89
+
90
+ ---
91
+
92
+ ## ✨ Features
93
+
94
+ - **Real-Time Messaging**: Built on Server-Sent Events (SSE) for zero-latency multiplayer updates.
95
+ - **Secure Alias Locking**: Users can register and lock their alias with a password. Password hashes are calculated locally and checked securely using SHA-256 and salt on the database.
96
+ - **Input Masking**: Passwords and confirmation queries are muted on the terminal during entry.
97
+ - **Anti-Spam Rate Limiting**: Built-in IP-based Token Bucket rate limiting (capacity: 5 requests, refilling 1 token every 1.5 seconds) to prevent bot spam.
98
+ - **Auto-Cleanup Cron**: Cloudflare worker triggers hourly routines to automatically prune chat logs older than 6 hours.
99
+ - **Terminal XSS Protection**: Strip ANSI escape sequences from incoming user payloads to prevent control character injection attacks.
package/index.js CHANGED
@@ -1,6 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import readline from 'readline';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ // Simple .env file loader for Node.js
9
+ function loadEnv() {
10
+ try {
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const envPath = path.resolve(__dirname, '.env');
13
+ if (fs.existsSync(envPath)) {
14
+ const content = fs.readFileSync(envPath, 'utf8');
15
+ for (const line of content.split('\n')) {
16
+ const trimmedLine = line.trim();
17
+ if (!trimmedLine || trimmedLine.startsWith('#')) continue;
18
+ const parts = trimmedLine.split('=');
19
+ if (parts.length >= 2) {
20
+ const key = parts[0].trim();
21
+ const value = parts.slice(1).join('=').trim().replace(/(^['"]|['"]$)/g, '');
22
+ if (key && !process.env[key]) {
23
+ process.env[key] = value;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ } catch (e) {
29
+ // Ignore env loading errors
30
+ }
31
+ }
32
+
33
+ loadEnv();
34
+
4
35
  import {
5
36
  clearScreen,
6
37
  drawBanner,
@@ -15,8 +46,9 @@ import {
15
46
  restoreCursor,
16
47
  clearCurrentLine
17
48
  } from './src/ui.js';
18
- import { setAlias, getAlias } from './src/config.js';
19
- import { connectToStream, sendMessage } from './src/api.js';
49
+ import { setAlias, getAlias, setToken } from './src/config.js';
50
+ import { connectToStream, sendMessage, checkAliasStatus, registerAlias, verifyAlias } from './src/api.js';
51
+ import { encrypt, decrypt, isUsingCustomPassphrase } from './src/crypto.js';
20
52
 
21
53
  let abortController = null;
22
54
 
@@ -29,12 +61,16 @@ const rl = readline.createInterface({
29
61
  const messages = [];
30
62
  let chatActive = false;
31
63
  let muteNewline = false;
64
+ let muteInput = false;
32
65
 
33
66
  // Override stdout.write to intercept the readline newline on enter keypress.
34
67
  // This prevents the entire terminal window from scrolling up when the user submits a message.
35
68
  const originalWrite = process.stdout.write.bind(process.stdout);
36
69
  process.stdout.write = (chunk, encoding, callback) => {
37
70
  const data = chunk.toString();
71
+ if (muteInput && data !== '\n' && data !== '\r\n' && data !== '\r') {
72
+ return true;
73
+ }
38
74
  if (muteNewline && (data === '\n' || data === '\r\n' || data === '\r')) {
39
75
  return true;
40
76
  }
@@ -54,7 +90,7 @@ function promptAlias() {
54
90
 
55
91
  process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}Choose an alias: ${COLORS.RESET}`);
56
92
 
57
- rl.question('', (input) => {
93
+ rl.question('', async (input) => {
58
94
  const alias = input.trim();
59
95
  if (!alias) {
60
96
  process.stdout.write('\n' + formatError('Alias cannot be empty. Please try again.') + '\n');
@@ -62,8 +98,115 @@ function promptAlias() {
62
98
  return;
63
99
  }
64
100
 
65
- setAlias(alias);
66
- initChat();
101
+ try {
102
+ const { locked } = await checkAliasStatus(alias);
103
+ if (locked) {
104
+ promptPassword(alias);
105
+ } else {
106
+ promptLockOption(alias);
107
+ }
108
+ } catch (err) {
109
+ process.stdout.write('\n' + formatSystem(`Could not verify alias status (${err.message}). Joining as guest...`) + '\n');
110
+ setTimeout(() => {
111
+ setAlias(alias);
112
+ setToken('');
113
+ initChat();
114
+ }, 1500);
115
+ }
116
+ });
117
+ }
118
+
119
+ function promptPassword(alias) {
120
+ process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}This alias is locked. Enter password: ${COLORS.RESET}`);
121
+
122
+ muteInput = true;
123
+ rl.question('', async (password) => {
124
+ muteInput = false;
125
+ process.stdout.write('\n');
126
+
127
+ const pw = password.trim();
128
+ if (!pw) {
129
+ process.stdout.write(formatError('Password cannot be empty. Please try again.') + '\n');
130
+ setTimeout(() => promptPassword(alias), 1500);
131
+ return;
132
+ }
133
+
134
+ try {
135
+ const res = await verifyAlias(alias, pw);
136
+ if (res.success && res.token) {
137
+ setAlias(alias);
138
+ setToken(res.token);
139
+ initChat();
140
+ } else {
141
+ process.stdout.write(formatError('Failed to verify alias.') + '\n');
142
+ setTimeout(promptAlias, 1500);
143
+ }
144
+ } catch (err) {
145
+ process.stdout.write(formatError(err.message || 'Incorrect password or verification error.') + '\n');
146
+ setTimeout(promptAlias, 1500);
147
+ }
148
+ });
149
+ }
150
+
151
+ function promptLockOption(alias) {
152
+ process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}Would you like to lock @${alias} with a password? (y/n): ${COLORS.RESET}`);
153
+
154
+ rl.question('', (ans) => {
155
+ const response = ans.trim().toLowerCase();
156
+ if (response === 'y' || response === 'yes') {
157
+ promptCreatePassword(alias);
158
+ } else {
159
+ setAlias(alias);
160
+ setToken('');
161
+ initChat();
162
+ }
163
+ });
164
+ }
165
+
166
+ function promptCreatePassword(alias) {
167
+ process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}Create password: ${COLORS.RESET}`);
168
+
169
+ muteInput = true;
170
+ rl.question('', (pw1) => {
171
+ muteInput = false;
172
+ process.stdout.write('\n');
173
+
174
+ if (!pw1.trim()) {
175
+ process.stdout.write(formatError('Password cannot be empty.') + '\n');
176
+ setTimeout(() => promptCreatePassword(alias), 1500);
177
+ return;
178
+ }
179
+
180
+ process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}Confirm password: ${COLORS.RESET}`);
181
+ muteInput = true;
182
+ rl.question('', async (pw2) => {
183
+ muteInput = false;
184
+ process.stdout.write('\n');
185
+
186
+ if (pw1 !== pw2) {
187
+ process.stdout.write(formatError('Passwords do not match. Let\'s try again.') + '\n');
188
+ setTimeout(() => promptCreatePassword(alias), 1500);
189
+ return;
190
+ }
191
+
192
+ try {
193
+ const res = await registerAlias(alias, pw1);
194
+ if (res.success && res.token) {
195
+ process.stdout.write(formatSystem(`Alias @${alias} successfully locked!`) + '\n');
196
+ setTimeout(() => {
197
+ setAlias(alias);
198
+ setToken(res.token);
199
+ initChat();
200
+ }, 1500);
201
+ } else {
202
+ process.stdout.write(formatError('Registration failed.') + '\n');
203
+ setTimeout(promptAlias, 1500);
204
+ }
205
+ } catch (err) {
206
+ process.stdout.write(formatError(err.message || 'Error locking alias.') + '\n');
207
+ setTimeout(promptAlias, 1500);
208
+ }
209
+ });
67
210
  });
68
211
  }
69
212
 
@@ -76,6 +219,11 @@ function initChat() {
76
219
  // Add welcome system messages
77
220
  addSystemMessage(`Welcome @${getAlias()} to the HACKER LOBBY!`);
78
221
  addSystemMessage(`Type your message and press Enter. Type "/exit" to leave.`);
222
+ addSystemMessage(
223
+ isUsingCustomPassphrase()
224
+ ? '🔒 E2EE active (using custom LOBBY_PASSPHRASE)'
225
+ : '🔒 E2EE active (using default shared lobby key)'
226
+ );
79
227
 
80
228
  // Set up prompt
81
229
  const promptStr = `${COLORS.CYAN}${COLORS.BOLD}[${getAlias()}]: ${COLORS.RESET}`;
@@ -84,11 +232,14 @@ function initChat() {
84
232
  // Connect to backend Server-Sent Events stream
85
233
  abortController = new AbortController();
86
234
  connectToStream((message) => {
87
- addMessage(message.username, message.content);
235
+ addMessage(message.username, decrypt(message.content));
88
236
  }, abortController.signal).catch((err) => {
89
237
  addSystemMessage(`Stream disconnected: ${err.message}`);
90
238
  });
91
239
 
240
+ // Post join message to the server
241
+ sendMessage(getAlias(), encrypt('joined the chat')).catch(() => {});
242
+
92
243
  rl.on('line', (line) => {
93
244
  // Disable newline muting once readline has finished processing the line
94
245
  muteNewline = false;
@@ -100,7 +251,7 @@ function initChat() {
100
251
  }
101
252
 
102
253
  // Post the message to the Edge server
103
- sendMessage(getAlias(), text).catch((err) => {
254
+ sendMessage(getAlias(), encrypt(text)).catch((err) => {
104
255
  addSystemMessage(`Failed to send message: ${err.message}`);
105
256
  });
106
257
  }
@@ -119,6 +270,11 @@ function initChat() {
119
270
  }
120
271
  });
121
272
 
273
+ // Handle Ctrl+C gracefully
274
+ rl.on('SIGINT', () => {
275
+ cleanupAndExit();
276
+ });
277
+
122
278
  // Initial prompt display
123
279
  rl.prompt(true);
124
280
  }
@@ -143,7 +299,7 @@ function drawLayout() {
143
299
 
144
300
  // 2. Draw static divider line just above the input prompt
145
301
  moveCursor(rows - 1, 1);
146
- process.stdout.write(COLORS.GRAY + '─'.repeat(cols) + COLORS.RESET);
302
+ process.stdout.write(COLORS.GRAY + '-'.repeat(cols) + COLORS.RESET);
147
303
 
148
304
  // 3. Set the scrolling region for messages
149
305
  setScrollRegion(topMargin, bottomMargin);
@@ -207,10 +363,14 @@ function addSystemMessage(text) {
207
363
  moveCursor(rows, col);
208
364
  }
209
365
 
210
- function cleanupAndExit() {
366
+ async function cleanupAndExit() {
211
367
  if (abortController) {
212
368
  abortController.abort();
213
369
  }
370
+ try {
371
+ // Send leave notification to server before exiting
372
+ await sendMessage(getAlias(), encrypt('left the chat'));
373
+ } catch (_) {}
214
374
  resetScrollRegion();
215
375
  clearScreen();
216
376
  console.log(formatSystem('Goodbye!'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hacker-lobby",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Multiplayer terminal chat application",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,4 +17,4 @@
17
17
  "engines": {
18
18
  "node": ">=16.0.0"
19
19
  }
20
- }
20
+ }
package/src/api.js CHANGED
@@ -1,4 +1,12 @@
1
- const API_URL = process.env.API_URL || 'https://hacker-lobby-backend.spidozx.workers.dev';
1
+ import { getToken } from './config.js';
2
+
3
+ function getApiUrl() {
4
+ const url = process.env.API_URL;
5
+ if (!url) {
6
+ throw new Error('API_URL environment variable is missing. Please make sure you have a .env file configured locally.');
7
+ }
8
+ return url;
9
+ }
2
10
 
3
11
  /**
4
12
  * Connects to the SSE stream at /listen and parses incoming messages in real-time.
@@ -6,7 +14,7 @@ const API_URL = process.env.API_URL || 'https://hacker-lobby-backend.spidozx.wor
6
14
  * @param {AbortSignal} [signal] - Optional signal to abort/disconnect the connection.
7
15
  */
8
16
  export async function connectToStream(onMessageCallback, signal) {
9
- const url = `${API_URL}/listen`;
17
+ const url = `${getApiUrl()}/listen`;
10
18
 
11
19
  try {
12
20
  const response = await fetch(url, { signal });
@@ -57,20 +65,86 @@ export async function connectToStream(onMessageCallback, signal) {
57
65
  * @returns {Promise<Object>} Response JSON.
58
66
  */
59
67
  export async function sendMessage(user, text) {
60
- const url = `${API_URL}/say`;
68
+ const url = `${getApiUrl()}/say`;
69
+ const token = getToken();
70
+
71
+ const response = await fetch(url, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify({ user, text, token }),
77
+ });
78
+
79
+ if (!response.ok) {
80
+ const errData = await response.json().catch(() => ({}));
81
+ throw new Error(errData.error || `HTTP error! Status: ${response.status}`);
82
+ }
83
+
84
+ return response.json();
85
+ }
61
86
 
87
+ /**
88
+ * Checks if an alias is registered and locked.
89
+ * @param {string} alias
90
+ * @returns {Promise<{ locked: boolean }>}
91
+ */
92
+ export async function checkAliasStatus(alias) {
93
+ const url = `${getApiUrl()}/alias/check`;
62
94
  const response = await fetch(url, {
63
95
  method: 'POST',
64
96
  headers: {
65
97
  'Content-Type': 'application/json',
66
98
  },
67
- body: JSON.stringify({ user, text }),
99
+ body: JSON.stringify({ alias }),
68
100
  });
101
+ if (!response.ok) {
102
+ const errData = await response.json().catch(() => ({}));
103
+ throw new Error(errData.error || `HTTP error! Status: ${response.status}`);
104
+ }
105
+ return response.json();
106
+ }
69
107
 
108
+ /**
109
+ * Registers/locks an alias with a password.
110
+ * @param {string} alias
111
+ * @param {string} password
112
+ * @returns {Promise<{ success: boolean, token: string }>}
113
+ */
114
+ export async function registerAlias(alias, password) {
115
+ const url = `${getApiUrl()}/alias/register`;
116
+ const response = await fetch(url, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ },
121
+ body: JSON.stringify({ alias, password }),
122
+ });
70
123
  if (!response.ok) {
71
124
  const errData = await response.json().catch(() => ({}));
72
125
  throw new Error(errData.error || `HTTP error! Status: ${response.status}`);
73
126
  }
127
+ return response.json();
128
+ }
74
129
 
130
+ /**
131
+ * Verifies the password for a locked alias.
132
+ * @param {string} alias
133
+ * @param {string} password
134
+ * @returns {Promise<{ success: boolean, token: string }>}
135
+ */
136
+ export async function verifyAlias(alias, password) {
137
+ const url = `${getApiUrl()}/alias/verify`;
138
+ const response = await fetch(url, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ },
143
+ body: JSON.stringify({ alias, password }),
144
+ });
145
+ if (!response.ok) {
146
+ const errData = await response.json().catch(() => ({}));
147
+ throw new Error(errData.error || `HTTP error! Status: ${response.status}`);
148
+ }
75
149
  return response.json();
76
150
  }
package/src/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const state = {
2
2
  alias: '',
3
+ token: '',
3
4
  };
4
5
 
5
6
  /**
@@ -17,3 +18,19 @@ export function setAlias(alias) {
17
18
  export function getAlias() {
18
19
  return state.alias;
19
20
  }
21
+
22
+ /**
23
+ * Set the user's authentication token
24
+ * @param {string} token
25
+ */
26
+ export function setToken(token) {
27
+ state.token = token ? token.trim() : '';
28
+ }
29
+
30
+ /**
31
+ * Get the user's authentication token
32
+ * @returns {string}
33
+ */
34
+ export function getToken() {
35
+ return state.token;
36
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,63 @@
1
+ import crypto from 'crypto';
2
+
3
+ // Get key from environment variable or use a secure default
4
+ const PASSPHRASE = process.env.LOBBY_PASSPHRASE || 'hacker-lobby-default-secure-passphrase-2026';
5
+
6
+ // Derive 32-byte key from passphrase using SHA-256
7
+ const KEY = crypto.createHash('sha256').update(PASSPHRASE).digest();
8
+
9
+ const ALGORITHM = 'aes-256-cbc';
10
+ const IV_LENGTH = 16;
11
+
12
+ /**
13
+ * Encrypts a plaintext string.
14
+ * Returns a string formatted as "iv_hex:ciphertext_hex"
15
+ * If encryption fails, returns the original text (fallback)
16
+ * @param {string} text
17
+ * @returns {string}
18
+ */
19
+ export function encrypt(text) {
20
+ try {
21
+ const iv = crypto.randomBytes(IV_LENGTH);
22
+ const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
23
+ let encrypted = cipher.update(text, 'utf8', 'hex');
24
+ encrypted += cipher.final('hex');
25
+ return `${iv.toString('hex')}:${encrypted}`;
26
+ } catch (err) {
27
+ return text;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Decrypts an encrypted string formatted as "iv_hex:ciphertext_hex".
33
+ * If decryption fails or if it's not encrypted, returns a fallback representation or the original text.
34
+ * @param {string} encryptedText
35
+ * @returns {string}
36
+ */
37
+ export function decrypt(encryptedText) {
38
+ try {
39
+ if (!encryptedText || typeof encryptedText !== 'string' || !encryptedText.includes(':')) {
40
+ return '🔒 [Encrypted Message]';
41
+ }
42
+ const [ivHex, ciphertextHex] = encryptedText.split(':');
43
+ if (ivHex.length !== 32 || !/^[0-9a-fA-F]+$/.test(ivHex) || !/^[0-9a-fA-F]+$/.test(ciphertextHex)) {
44
+ return '🔒 [Encrypted Message]';
45
+ }
46
+ const iv = Buffer.from(ivHex, 'hex');
47
+ const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
48
+ let decrypted = decipher.update(ciphertextHex, 'hex', 'utf8');
49
+ decrypted += decipher.final('utf8');
50
+ return decrypted;
51
+ } catch (err) {
52
+ // Decryption failed (probably wrong passphrase/key)
53
+ return '🔒 [Encrypted Message]';
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Returns true if a custom passphrase is set in the environment.
59
+ * @returns {boolean}
60
+ */
61
+ export function isUsingCustomPassphrase() {
62
+ return !!process.env.LOBBY_PASSPHRASE;
63
+ }
package/src/ui.js CHANGED
@@ -54,6 +54,12 @@ ${COLORS.CYAN} ██╗ ██╗ █████╗ ██████╗█
54
54
  * @returns {string}
55
55
  */
56
56
  export function formatMessage(sender, text) {
57
+ if (text === 'joined the chat') {
58
+ return `${COLORS.GREEN}${COLORS.BOLD}[+]${COLORS.RESET} ${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET} ${COLORS.NEON_GREEN}joined the chat${COLORS.RESET}`;
59
+ }
60
+ if (text === 'left the chat') {
61
+ return `${COLORS.RED}${COLORS.BOLD}[-]${COLORS.RESET} ${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET} ${COLORS.RED}left the chat${COLORS.RESET}`;
62
+ }
57
63
  return `${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET}: ${text}`;
58
64
  }
59
65
 
package/src/worker.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // Regex to match ANSI escape sequences (control codes, color formatting, etc.)
2
2
  const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
3
3
 
4
+ // Regex to match encrypted content in the format ivHex:ciphertextHex
5
+ const encryptedRegex = /^[0-9a-fA-F]{32}:[0-9a-fA-F]+$/;
6
+
4
7
  /**
5
8
  * Sanitizes input strings by stripping out all ANSI escape sequences to prevent terminal XSS.
6
9
  * @param {string} str
@@ -57,6 +60,125 @@ async function pollAndStream(writer, env, request) {
57
60
  }
58
61
  }
59
62
 
63
+ async function hashPassword(password) {
64
+ const msgUint8 = new TextEncoder().encode(password);
65
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
66
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
67
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
68
+ return hashHex;
69
+ }
70
+
71
+ const SESSION_SECRET_FALLBACK = 'hacker-lobby-secret-key-change-me';
72
+
73
+ async function generateToken(alias, secret) {
74
+ const keySecret = secret || SESSION_SECRET_FALLBACK;
75
+ const expiry = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
76
+ const message = `${alias}:${expiry}`;
77
+ const encoder = new TextEncoder();
78
+ const keyData = encoder.encode(keySecret);
79
+ const key = await crypto.subtle.importKey(
80
+ 'raw',
81
+ keyData,
82
+ { name: 'HMAC', hash: 'SHA-256' },
83
+ false,
84
+ ['sign']
85
+ );
86
+ const signature = await crypto.subtle.sign(
87
+ 'HMAC',
88
+ key,
89
+ encoder.encode(message)
90
+ );
91
+ const sigHex = Array.from(new Uint8Array(signature))
92
+ .map(b => b.toString(16).padStart(2, '0'))
93
+ .join('');
94
+ return `${message}:${sigHex}`;
95
+ }
96
+
97
+ async function verifyToken(token, secret) {
98
+ if (!token) return null;
99
+ const keySecret = secret || SESSION_SECRET_FALLBACK;
100
+ try {
101
+ const parts = token.split(':');
102
+ if (parts.length !== 3) return null;
103
+ const [alias, expiryStr, sigHex] = parts;
104
+ const expiry = parseInt(expiryStr, 10);
105
+ if (expiry < Date.now()) return null; // Expired
106
+
107
+ const message = `${alias}:${expiryStr}`;
108
+ const encoder = new TextEncoder();
109
+ const keyData = encoder.encode(keySecret);
110
+ const key = await crypto.subtle.importKey(
111
+ 'raw',
112
+ keyData,
113
+ { name: 'HMAC', hash: 'SHA-256' },
114
+ false,
115
+ ['verify']
116
+ );
117
+
118
+ // Convert hex signature back to bytes
119
+ const sigBytes = new Uint8Array(
120
+ sigHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
121
+ );
122
+ const isValid = await crypto.subtle.verify(
123
+ 'HMAC',
124
+ key,
125
+ sigBytes,
126
+ encoder.encode(message)
127
+ );
128
+ return isValid ? alias : null;
129
+ } catch (e) {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ const BUCKET_CAPACITY = 5.0;
135
+ const REFILL_RATE_PER_MS = 1.0 / 1500.0; // 1 token every 1.5 seconds
136
+
137
+ async function isRateLimited(ip, env) {
138
+ if (!env.DB) return false;
139
+ const now = Date.now();
140
+ try {
141
+ const record = await env.DB.prepare(
142
+ 'SELECT last_request_time, tokens FROM rate_limits WHERE ip = ?'
143
+ )
144
+ .bind(ip)
145
+ .first();
146
+
147
+ if (!record) {
148
+ const initialTokens = BUCKET_CAPACITY - 1.0;
149
+ await env.DB.prepare(
150
+ 'INSERT INTO rate_limits (ip, last_request_time, tokens) VALUES (?, ?, ?)'
151
+ )
152
+ .bind(ip, now, initialTokens)
153
+ .run();
154
+ return false;
155
+ }
156
+
157
+ const lastRequestTime = record.last_request_time;
158
+ const oldTokens = record.tokens;
159
+ const elapsed = now - lastRequestTime;
160
+ let tokens = oldTokens + elapsed * REFILL_RATE_PER_MS;
161
+ if (tokens > BUCKET_CAPACITY) {
162
+ tokens = BUCKET_CAPACITY;
163
+ }
164
+
165
+ if (tokens >= 1.0) {
166
+ const nextTokens = tokens - 1.0;
167
+ await env.DB.prepare(
168
+ 'UPDATE rate_limits SET last_request_time = ?, tokens = ? WHERE ip = ?'
169
+ )
170
+ .bind(now, nextTokens, ip)
171
+ .run();
172
+ return false;
173
+ } else {
174
+ return true;
175
+ }
176
+ } catch (err) {
177
+ console.error('Rate limit error:', err);
178
+ return false;
179
+ }
180
+ }
181
+
60
182
  export default {
61
183
  /**
62
184
  * Fetch handler for Cloudflare Worker.
@@ -117,6 +239,130 @@ export default {
117
239
  });
118
240
  }
119
241
 
242
+ // Routing for POST /alias/check
243
+ if (url.pathname === '/alias/check') {
244
+ if (request.method !== 'POST') {
245
+ return jsonResponse({ error: 'Method Not Allowed' }, 405);
246
+ }
247
+ try {
248
+ const body = await request.json();
249
+ const { alias } = body || {};
250
+ if (!alias || typeof alias !== 'string' || !alias.trim()) {
251
+ return jsonResponse({ error: 'Missing or invalid "alias" parameter' }, 400);
252
+ }
253
+ const cleanAlias = sanitizeAnsi(alias.trim());
254
+
255
+ // Ensure database binding exists
256
+ if (!env.DB) {
257
+ return jsonResponse({ error: 'Database binding "DB" is not configured' }, 500);
258
+ }
259
+
260
+ const existing = await env.DB.prepare(
261
+ 'SELECT alias FROM aliases WHERE alias = ?'
262
+ )
263
+ .bind(cleanAlias)
264
+ .first();
265
+
266
+ return jsonResponse({ locked: !!existing });
267
+ } catch (err) {
268
+ return jsonResponse({ error: `Bad Request: ${err.message}` }, 400);
269
+ }
270
+ }
271
+
272
+ // Routing for POST /alias/register
273
+ if (url.pathname === '/alias/register') {
274
+ if (request.method !== 'POST') {
275
+ return jsonResponse({ error: 'Method Not Allowed' }, 405);
276
+ }
277
+ try {
278
+ const body = await request.json();
279
+ const { alias, password } = body || {};
280
+ if (!alias || typeof alias !== 'string' || !alias.trim()) {
281
+ return jsonResponse({ error: 'Missing or invalid "alias" parameter' }, 400);
282
+ }
283
+ if (!password || typeof password !== 'string' || !password.trim()) {
284
+ return jsonResponse({ error: 'Missing or invalid "password" parameter' }, 400);
285
+ }
286
+ const cleanAlias = sanitizeAnsi(alias.trim());
287
+
288
+ // Ensure database binding exists
289
+ if (!env.DB) {
290
+ return jsonResponse({ error: 'Database binding "DB" is not configured' }, 500);
291
+ }
292
+
293
+ // Check if already registered
294
+ const existing = await env.DB.prepare(
295
+ 'SELECT alias FROM aliases WHERE alias = ?'
296
+ )
297
+ .bind(cleanAlias)
298
+ .first();
299
+
300
+ if (existing) {
301
+ return jsonResponse({ error: 'Alias is already locked' }, 400);
302
+ }
303
+
304
+ const passwordHash = await hashPassword(password);
305
+
306
+ await env.DB.prepare(
307
+ 'INSERT INTO aliases (alias, password_hash) VALUES (?, ?)'
308
+ )
309
+ .bind(cleanAlias, passwordHash)
310
+ .run();
311
+
312
+ const secret = env.SESSION_SECRET || env.JWT_SECRET;
313
+ const token = await generateToken(cleanAlias, secret);
314
+
315
+ return jsonResponse({ success: true, token }, 201);
316
+ } catch (err) {
317
+ return jsonResponse({ error: `Bad Request: ${err.message}` }, 400);
318
+ }
319
+ }
320
+
321
+ // Routing for POST /alias/verify
322
+ if (url.pathname === '/alias/verify') {
323
+ if (request.method !== 'POST') {
324
+ return jsonResponse({ error: 'Method Not Allowed' }, 405);
325
+ }
326
+ try {
327
+ const body = await request.json();
328
+ const { alias, password } = body || {};
329
+ if (!alias || typeof alias !== 'string' || !alias.trim()) {
330
+ return jsonResponse({ error: 'Missing or invalid "alias" parameter' }, 400);
331
+ }
332
+ if (!password || typeof password !== 'string' || !password.trim()) {
333
+ return jsonResponse({ error: 'Missing or invalid "password" parameter' }, 400);
334
+ }
335
+ const cleanAlias = sanitizeAnsi(alias.trim());
336
+
337
+ // Ensure database binding exists
338
+ if (!env.DB) {
339
+ return jsonResponse({ error: 'Database binding "DB" is not configured' }, 500);
340
+ }
341
+
342
+ const record = await env.DB.prepare(
343
+ 'SELECT password_hash FROM aliases WHERE alias = ?'
344
+ )
345
+ .bind(cleanAlias)
346
+ .first();
347
+
348
+ if (!record) {
349
+ return jsonResponse({ error: 'Alias is not locked/registered' }, 404);
350
+ }
351
+
352
+ const passwordHash = await hashPassword(password);
353
+ if (record.password_hash !== passwordHash) {
354
+ return jsonResponse({ error: 'Invalid password' }, 401);
355
+ }
356
+
357
+ const secret = env.SESSION_SECRET || env.JWT_SECRET;
358
+ const token = await generateToken(cleanAlias, secret);
359
+
360
+ return jsonResponse({ success: true, token });
361
+ } catch (err) {
362
+ return jsonResponse({ error: `Bad Request: ${err.message}` }, 400);
363
+ }
364
+ }
365
+
120
366
  // Routing for POST /say
121
367
  if (url.pathname === '/say') {
122
368
  if (request.method !== 'POST') {
@@ -124,8 +370,14 @@ export default {
124
370
  }
125
371
 
126
372
  try {
373
+ const ip = request.headers.get('CF-Connecting-IP') || '127.0.0.1';
374
+ const isLimited = await isRateLimited(ip, env);
375
+ if (isLimited) {
376
+ return jsonResponse({ error: 'Rate limit exceeded. Please wait before sending more messages.' }, 429);
377
+ }
378
+
127
379
  const body = await request.json();
128
- const { user, text } = body || {};
380
+ const { user, text, token } = body || {};
129
381
 
130
382
  // Validation: parameters must exist, be strings, and not be empty
131
383
  if (!user || typeof user !== 'string' || !user.trim()) {
@@ -136,6 +388,10 @@ export default {
136
388
  return jsonResponse({ error: 'Missing or invalid "text" parameter' }, 400);
137
389
  }
138
390
 
391
+ if (!encryptedRegex.test(text)) {
392
+ return jsonResponse({ error: 'Bad Request: Message content must be encrypted.' }, 400);
393
+ }
394
+
139
395
  // Sanitize username and content of ANSI escape sequences to prevent terminal XSS
140
396
  const sanitizedUser = sanitizeAnsi(user);
141
397
  const sanitizedText = sanitizeAnsi(text);
@@ -145,6 +401,21 @@ export default {
145
401
  return jsonResponse({ error: 'Database binding "DB" is not configured' }, 500);
146
402
  }
147
403
 
404
+ // Authentication check: if alias is locked, verify token
405
+ const record = await env.DB.prepare(
406
+ 'SELECT alias FROM aliases WHERE alias = ?'
407
+ )
408
+ .bind(sanitizedUser)
409
+ .first();
410
+
411
+ if (record) {
412
+ const secret = env.SESSION_SECRET || env.JWT_SECRET;
413
+ const verifiedAlias = await verifyToken(token, secret);
414
+ if (!verifiedAlias || verifiedAlias !== sanitizedUser) {
415
+ return jsonResponse({ error: 'Unauthorized: This alias is locked and requires a valid session token' }, 401);
416
+ }
417
+ }
418
+
148
419
  // Insert into D1 messages table safely using parameterized bindings
149
420
  await env.DB.prepare(
150
421
  'INSERT INTO messages (username, content) VALUES (?, ?)'