1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
6import * as db from "./db.ts";
7
8// WebSocket message types
9interface AddItemsMessage {
10 type: 'add_items';
11 items: unknown;
12}
13
14interface VoteMessage {
15 type: 'vote';
16 itemId: unknown;
17 voteType: unknown;
18}
19
20interface UnvoteMessage {
21 type: 'unvote';
22 itemId: unknown;
23}
24
25interface EditItemMessage {
26 type: 'edit_item';
27 itemId: unknown;
28 text: unknown;
29}
30
31interface DeleteItemMessage {
32 type: 'delete_item';
33 itemId: unknown;
34}
35
36interface ResetVotesMessage {
37 type: 'reset_votes';
38}
39
40interface SetTitleMessage {
41 type: 'set_title';
42 title: unknown;
43}
44
45interface BreakTieMessage {
46 type: 'break_tie';
47}
48
49type ClientMessage = AddItemsMessage | VoteMessage | UnvoteMessage | EditItemMessage | DeleteItemMessage | ResetVotesMessage | SetTitleMessage | BreakTieMessage;
50
51const clients = new Map<string, Set<WebSocket>>();
52
53function generateRoomId(): string {
54 return crypto.randomUUID();
55}
56
57function broadcast(roomCode: string, message: unknown, except?: WebSocket) {
58 const room = clients.get(roomCode);
59 if (!room) return;
60
61 const payload = JSON.stringify(message);
62 for (const client of room) {
63 if (client !== except && client.readyState === WebSocket.OPEN) {
64 client.send(payload);
65 }
66 }
67}
68
69// Validation constants
70const MAX_ITEM_TEXT_LEN = 200;
71const MAX_BULK_ITEMS = 100;
72const MAX_WS_MESSAGE_BYTES = 32768;
73
74// Validation helpers
75function safeParseMessage(ws: WebSocket, raw: string): unknown | null {
76 if (raw.length > MAX_WS_MESSAGE_BYTES) {
77 console.warn(`Message too large: ${raw.length} bytes`);
78 ws.close(1009, 'Message too large');
79 return null;
80 }
81
82 try {
83 return JSON.parse(raw);
84 } catch (err) {
85 console.warn('Invalid JSON:', err);
86 return null;
87 }
88}
89
90function sanitizeItemText(s: string): string | null {
91 // Trim and collapse internal CRLF to \n
92 const cleaned = s.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
93
94 if (cleaned.length < 1 || cleaned.length > MAX_ITEM_TEXT_LEN) {
95 return null;
96 }
97
98 return cleaned;
99}
100
101function isValidVoteType(x: unknown): x is 'up' | 'down' | 'veto' {
102 return x === 'up' || x === 'down' || x === 'veto';
103}
104
105function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
106 // Add to room
107 if (!clients.has(roomCode)) {
108 clients.set(roomCode, new Set());
109 }
110 clients.get(roomCode)!.add(ws);
111
112 ws.onopen = () => {
113 // Send current state once connection is open
114 const state = db.getState(roomCode);
115 ws.send(JSON.stringify({
116 type: 'state',
117 items: state.items,
118 roomTitle: state.roomTitle,
119 userId
120 }));
121 };
122
123 ws.onmessage = (event) => {
124 const msg = safeParseMessage(ws, event.data);
125 if (!msg || typeof msg !== 'object' || !('type' in msg)) return;
126
127 try {
128 switch ((msg as ClientMessage).type) {
129 case 'add_items': {
130 const rawItems = (msg as AddItemsMessage).items;
131 if (!Array.isArray(rawItems)) {
132 console.warn('add_items: items is not an array');
133 return;
134 }
135
136 if (rawItems.length < 1 || rawItems.length > MAX_BULK_ITEMS) {
137 console.warn(`add_items: invalid count ${rawItems.length}`);
138 return;
139 }
140
141 const items = rawItems
142 .map((text: unknown) => {
143 if (typeof text !== 'string') return null;
144 const sanitized = sanitizeItemText(text);
145 return sanitized ? { id: crypto.randomUUID(), text: sanitized } : null;
146 })
147 .filter((item): item is { id: string; text: string } => item !== null);
148
149 if (items.length === 0) {
150 console.warn('add_items: no valid items after sanitization');
151 return;
152 }
153
154 try {
155 db.addItems(roomCode, items);
156 } catch (err) {
157 console.error('add_items transaction failed:', err);
158 return;
159 }
160
161 broadcast(roomCode, {
162 type: 'items_added',
163 items: items.map(i => ({ ...i, votes: {} }))
164 });
165 break;
166 }
167
168 case 'vote': {
169 const { itemId, voteType } = msg as VoteMessage;
170
171 if (typeof itemId !== 'string') {
172 console.warn('vote: itemId is not a string');
173 return;
174 }
175
176 if (!isValidVoteType(voteType)) {
177 console.warn(`vote: invalid voteType ${voteType}`);
178 return;
179 }
180
181 if (!db.itemBelongsToRoom(itemId, roomCode)) {
182 console.warn(`vote: item ${itemId} does not belong to room ${roomCode}`);
183 return;
184 }
185
186 db.upsertVote(itemId, userId, voteType);
187
188 broadcast(roomCode, {
189 type: 'vote_changed',
190 itemId,
191 userId,
192 voteType
193 });
194 break;
195 }
196
197 case 'unvote': {
198 const { itemId } = msg as UnvoteMessage;
199
200 if (typeof itemId !== 'string') {
201 console.warn('unvote: itemId is not a string');
202 return;
203 }
204
205 if (!db.itemBelongsToRoom(itemId, roomCode)) {
206 console.warn(`unvote: item ${itemId} does not belong to room ${roomCode}`);
207 return;
208 }
209
210 db.deleteVote(itemId, userId);
211
212 broadcast(roomCode, {
213 type: 'vote_removed',
214 itemId,
215 userId
216 });
217 break;
218 }
219
220 case 'edit_item': {
221 const { itemId, text } = msg as EditItemMessage;
222
223 if (typeof itemId !== 'string') {
224 console.warn('edit_item: itemId is not a string');
225 return;
226 }
227
228 if (typeof text !== 'string') {
229 console.warn('edit_item: text is not a string');
230 return;
231 }
232
233 const sanitized = sanitizeItemText(text);
234 if (!sanitized) {
235 console.warn('edit_item: text failed sanitization');
236 return;
237 }
238
239 if (!db.itemBelongsToRoom(itemId, roomCode)) {
240 console.warn(`edit_item: item ${itemId} does not belong to room ${roomCode}`);
241 return;
242 }
243
244 db.updateItemText(itemId, sanitized);
245
246 broadcast(roomCode, {
247 type: 'item_edited',
248 itemId,
249 text: sanitized
250 });
251 break;
252 }
253
254 case 'delete_item': {
255 const { itemId } = msg as DeleteItemMessage;
256
257 if (typeof itemId !== 'string') {
258 console.warn('delete_item: itemId is not a string');
259 return;
260 }
261
262 if (!db.itemBelongsToRoom(itemId, roomCode)) {
263 console.warn(`delete_item: item ${itemId} does not belong to room ${roomCode}`);
264 return;
265 }
266
267 db.deleteItem(itemId);
268
269 broadcast(roomCode, {
270 type: 'item_deleted',
271 itemId
272 });
273 break;
274 }
275
276 case 'reset_votes': {
277 db.resetVotes(roomCode);
278
279 broadcast(roomCode, {
280 type: 'votes_reset'
281 });
282 break;
283 }
284
285 case 'set_title': {
286 const { title } = msg as SetTitleMessage;
287
288 let sanitized: string | null = null;
289 if (title !== null && title !== undefined) {
290 if (typeof title !== 'string') {
291 console.warn('set_title: title is not a string');
292 return;
293 }
294 sanitized = sanitizeItemText(title);
295 if (!sanitized) {
296 console.warn('set_title: title failed sanitization');
297 return;
298 }
299 }
300
301 db.updateRoomTitle(roomCode, sanitized);
302
303 broadcast(roomCode, {
304 type: 'title_changed',
305 title: sanitized
306 });
307 break;
308 }
309
310 case 'break_tie': {
311 // Get all items in the room with their votes
312 const items = db.getItemsWithVotes(roomCode);
313
314 // Filter to non-vetoed items and calculate scores
315 const nonVetoed = items
316 .filter(item => {
317 const votes = item.votes ? item.votes.split(',') : [];
318 return !votes.includes('veto');
319 })
320 .map(item => {
321 const votes = item.votes ? item.votes.split(',') : [];
322 const score = votes.reduce((sum, v) =>
323 sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0);
324 return { id: item.id, text: item.text, score };
325 });
326
327 if (nonVetoed.length === 0) {
328 console.warn('break_tie: no non-vetoed items');
329 return;
330 }
331
332 // Find top score
333 const topScore = Math.max(...nonVetoed.map(item => item.score));
334 const tiedItems = nonVetoed.filter(item => item.score === topScore);
335
336 // Only break tie if there are multiple items at top score
337 if (tiedItems.length <= 1) {
338 console.warn('break_tie: no tie exists');
339 return;
340 }
341
342 // Randomly select one using crypto.getRandomValues for uniform distribution
343 const randomIndex = crypto.getRandomValues(new Uint32Array(1))[0] % tiedItems.length;
344 const chosen = tiedItems[randomIndex];
345
346 db.touchRoomActivity(roomCode);
347
348 broadcast(roomCode, {
349 type: 'tie_broken',
350 itemId: chosen.id,
351 text: chosen.text
352 });
353 break;
354 }
355 }
356 } catch (err) {
357 console.error('Message handling error:', err);
358 }
359 };
360
361 ws.onclose = () => {
362 const room = clients.get(roomCode);
363 if (room) {
364 room.delete(ws);
365 if (room.size === 0) {
366 clients.delete(roomCode);
367 }
368 }
369 };
370}
371
372// Room cleanup scheduler
373function cleanupInactiveRooms() {
374 const deleted = db.deleteInactiveRooms(30);
375 if (deleted > 0) {
376 console.log(`🧹 Cleaned up ${deleted} inactive room(s)`);
377 }
378}
379
380// Run cleanup on startup and schedule daily
381cleanupInactiveRooms();
382setInterval(cleanupInactiveRooms, 24 * 60 * 60 * 1000); // Every 24 hours
383
384// Parse command-line arguments
385let hostname = 'localhost';
386let port = 8294;
387for (let i = 0; i < Deno.args.length; i++) {
388 if (Deno.args[i] === '--host' && i + 1 < Deno.args.length) {
389 hostname = Deno.args[i + 1];
390 } else if (Deno.args[i] === '--port' && i + 1 < Deno.args.length) {
391 port = parseInt(Deno.args[i + 1], 10);
392 }
393}
394
395serve(async (req) => {
396 const url = new URL(req.url);
397
398 // Serve static files
399 if (url.pathname === "/" || url.pathname === "/index.html") {
400 const html = await Deno.readTextFile("./static/index.html");
401 return new Response(html, { headers: { "content-type": "text/html" } });
402 }
403 if (url.pathname === "/style.css") {
404 const css = await Deno.readTextFile("./static/style.css");
405 return new Response(css, { headers: { "content-type": "text/css" } });
406 }
407 if (url.pathname === "/palette.css") {
408 const css = await Deno.readTextFile("./static/palette.css");
409 return new Response(css, { headers: { "content-type": "text/css" } });
410 }
411 if (url.pathname === "/app.js") {
412 const js = await Deno.readTextFile("./static/app.js");
413 return new Response(js, { headers: { "content-type": "application/javascript" } });
414 }
415 if (url.pathname === "/render.js") {
416 const js = await Deno.readTextFile("./static/render.js");
417 return new Response(js, { headers: { "content-type": "application/javascript" } });
418 }
419 if (url.pathname === "/ui.js") {
420 const js = await Deno.readTextFile("./static/ui.js");
421 return new Response(js, { headers: { "content-type": "application/javascript" } });
422 }
423 if (url.pathname.startsWith("/icons/") && url.pathname.endsWith(".svg")) {
424 const iconName = url.pathname.slice(7); // Remove "/icons/"
425 try {
426 const svg = await Deno.readTextFile(`./static/icons/${iconName}`);
427 return new Response(svg, { headers: { "content-type": "image/svg+xml" } });
428 } catch {
429 return new Response("Not found", { status: 404 });
430 }
431 }
432
433 // Create new room
434 if (url.pathname === "/api/create") {
435 const code = generateRoomId();
436 db.createRoom(code);
437 return Response.json({ code });
438 }
439
440 // WebSocket connection
441 if (url.pathname === "/ws") {
442 const roomCode = url.searchParams.get("room");
443 const userId = url.searchParams.get("user") || crypto.randomUUID();
444
445 if (!roomCode) {
446 return new Response("Missing room code", { status: 400 });
447 }
448
449 // Verify room exists
450 if (!db.roomExists(roomCode)) {
451 return new Response("Room not found", { status: 404 });
452 }
453
454 const upgrade = req.headers.get("upgrade") || "";
455 if (upgrade.toLowerCase() !== "websocket") {
456 return new Response("Expected websocket", { status: 426 });
457 }
458
459 const { socket, response } = Deno.upgradeWebSocket(req);
460 handleWebSocket(socket, roomCode, userId);
461 return response;
462 }
463
464 return new Response("Not found", { status: 404 });
465}, {
466 port,
467 hostname,
468 onListen: () => {
469 console.log(`🌿 Sift ready at http://${hostname}:${port}`);
470 }
471});
472