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 +99 -0
- package/index.js +169 -9
- package/package.json +2 -2
- package/src/api.js +78 -4
- package/src/config.js +17 -0
- package/src/crypto.js +63 -0
- package/src/ui.js +6 -0
- package/src/worker.js +272 -1
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
|
-
|
|
66
|
-
|
|
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 + '
|
|
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
package/src/api.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
|
|
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 = `${
|
|
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 = `${
|
|
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({
|
|
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 (?, ?)'
|