server.ts

  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