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 broadcast(roomCode, {
347 type: 'tie_broken',
348 itemId: chosen.id,
349 text: chosen.text
350 });
351 break;
352 }
353 }
354 } catch (err) {
355 console.error('Message handling error:', err);
356 }
357 };
358
359 ws.onclose = () => {
360 const room = clients.get(roomCode);
361 if (room) {
362 room.delete(ws);
363 if (room.size === 0) {
364 clients.delete(roomCode);
365 }
366 }
367 };
368}
369
370serve(async (req) => {
371 const url = new URL(req.url);
372
373 // Serve static files
374 if (url.pathname === "/" || url.pathname === "/index.html") {
375 const html = await Deno.readTextFile("./static/index.html");
376 return new Response(html, { headers: { "content-type": "text/html" } });
377 }
378 if (url.pathname === "/style.css") {
379 const css = await Deno.readTextFile("./static/style.css");
380 return new Response(css, { headers: { "content-type": "text/css" } });
381 }
382 if (url.pathname === "/palette.css") {
383 const css = await Deno.readTextFile("./static/palette.css");
384 return new Response(css, { headers: { "content-type": "text/css" } });
385 }
386 if (url.pathname === "/app.js") {
387 const js = await Deno.readTextFile("./static/app.js");
388 return new Response(js, { headers: { "content-type": "application/javascript" } });
389 }
390 if (url.pathname === "/render.js") {
391 const js = await Deno.readTextFile("./static/render.js");
392 return new Response(js, { headers: { "content-type": "application/javascript" } });
393 }
394 if (url.pathname === "/ui.js") {
395 const js = await Deno.readTextFile("./static/ui.js");
396 return new Response(js, { headers: { "content-type": "application/javascript" } });
397 }
398 if (url.pathname.startsWith("/icons/") && url.pathname.endsWith(".svg")) {
399 const iconName = url.pathname.slice(7); // Remove "/icons/"
400 try {
401 const svg = await Deno.readTextFile(`./static/icons/${iconName}`);
402 return new Response(svg, { headers: { "content-type": "image/svg+xml" } });
403 } catch {
404 return new Response("Not found", { status: 404 });
405 }
406 }
407
408 // Create new room
409 if (url.pathname === "/api/create") {
410 const code = generateRoomId();
411 db.createRoom(code);
412 return Response.json({ code });
413 }
414
415 // WebSocket connection
416 if (url.pathname === "/ws") {
417 const roomCode = url.searchParams.get("room");
418 const userId = url.searchParams.get("user") || crypto.randomUUID();
419
420 if (!roomCode) {
421 return new Response("Missing room code", { status: 400 });
422 }
423
424 // Verify room exists
425 if (!db.roomExists(roomCode)) {
426 return new Response("Room not found", { status: 404 });
427 }
428
429 const upgrade = req.headers.get("upgrade") || "";
430 if (upgrade.toLowerCase() !== "websocket") {
431 return new Response("Expected websocket", { status: 426 });
432 }
433
434 const { socket, response } = Deno.upgradeWebSocket(req);
435 handleWebSocket(socket, roomCode, userId);
436 return response;
437 }
438
439 return new Response("Not found", { status: 404 });
440}, { port: 8294 });
441
442console.log("🌿 Sift ready at http://localhost:8294");