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 { Database } from "https://deno.land/x/sqlite3@0.11.1/mod.ts";
7
8const db = new Database("lists.db");
9
10// Enable foreign key constraints
11db.exec("PRAGMA foreign_keys = ON;");
12
13// Check schema version and migrate if needed
14const currentVersion = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version;
15
16if (currentVersion < 1) {
17 console.log('🔄 Migrating database schema to version 1...');
18
19 try {
20 db.exec('BEGIN');
21
22 // Temporarily disable foreign keys for migration
23 db.exec('PRAGMA foreign_keys = OFF');
24
25 // Create new tables with constraints
26 db.exec(`
27 CREATE TABLE rooms_new (
28 code TEXT PRIMARY KEY NOT NULL,
29 created_at INTEGER DEFAULT (unixepoch())
30 );
31
32 CREATE TABLE items_new (
33 id TEXT PRIMARY KEY NOT NULL,
34 room_code TEXT NOT NULL,
35 text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200),
36 created_at INTEGER DEFAULT (unixepoch()),
37 FOREIGN KEY (room_code) REFERENCES rooms_new(code) ON DELETE CASCADE
38 );
39
40 CREATE TABLE votes_new (
41 item_id TEXT NOT NULL,
42 user_id TEXT NOT NULL,
43 vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')),
44 created_at INTEGER DEFAULT (unixepoch()),
45 PRIMARY KEY (item_id, user_id),
46 FOREIGN KEY (item_id) REFERENCES items_new(id) ON DELETE CASCADE
47 );
48 `);
49
50 // Copy data if old tables exist, filtering/truncating as needed
51 const tableExists = (name: string) => {
52 const result = db.prepare(`
53 SELECT name FROM sqlite_master
54 WHERE type='table' AND name=?
55 `).get(name);
56 return result !== undefined;
57 };
58
59 if (tableExists('rooms')) {
60 db.exec(`
61 INSERT INTO rooms_new (code, created_at)
62 SELECT code, created_at
63 FROM rooms
64 `);
65 }
66
67 if (tableExists('items')) {
68 db.exec(`
69 INSERT INTO items_new (id, room_code, text, created_at)
70 SELECT i.id, i.room_code,
71 SUBSTR(TRIM(REPLACE(REPLACE(i.text, char(13)||char(10), char(10)), char(13), char(10))), 1, 200) as text,
72 i.created_at
73 FROM items i
74 WHERE i.room_code IN (SELECT code FROM rooms_new)
75 AND LENGTH(TRIM(i.text)) > 0
76 `);
77 }
78
79 if (tableExists('votes')) {
80 db.exec(`
81 INSERT INTO votes_new (item_id, user_id, vote_type, created_at)
82 SELECT v.item_id, v.user_id, v.vote_type, v.created_at
83 FROM votes v
84 WHERE v.item_id IN (SELECT id FROM items_new)
85 AND v.vote_type IN ('up', 'down', 'veto')
86 `);
87 }
88
89 // Drop old tables if they exist
90 if (tableExists('votes')) db.exec('DROP TABLE votes');
91 if (tableExists('items')) db.exec('DROP TABLE items');
92 if (tableExists('rooms')) db.exec('DROP TABLE rooms');
93
94 // Rename new tables
95 db.exec('ALTER TABLE rooms_new RENAME TO rooms');
96 db.exec('ALTER TABLE items_new RENAME TO items');
97 db.exec('ALTER TABLE votes_new RENAME TO votes');
98
99 // Create indexes
100 db.exec(`
101 CREATE INDEX idx_items_room_code ON items(room_code);
102 CREATE INDEX idx_votes_item_id ON votes(item_id);
103 CREATE INDEX idx_votes_user_id ON votes(user_id);
104 `);
105
106 // Re-enable foreign keys
107 db.exec('PRAGMA foreign_keys = ON');
108
109 // Set schema version
110 db.exec('PRAGMA user_version = 2');
111
112 db.exec('COMMIT');
113 console.log('✅ Migration complete');
114 } catch (err) {
115 db.exec('ROLLBACK');
116 console.error('❌ Migration failed:', err);
117 throw err;
118 }
119} else if (currentVersion === 1) {
120 console.log('🔄 Migrating database schema from version 1 to 2...');
121
122 try {
123 db.exec('BEGIN');
124 db.exec('PRAGMA foreign_keys = OFF');
125
126 // Create new rooms table without last_activity and length constraint
127 db.exec(`
128 CREATE TABLE rooms_new (
129 code TEXT PRIMARY KEY NOT NULL,
130 created_at INTEGER DEFAULT (unixepoch())
131 );
132 `);
133
134 // Copy existing rooms
135 db.exec(`
136 INSERT INTO rooms_new (code, created_at)
137 SELECT code, created_at
138 FROM rooms
139 `);
140
141 // Drop old and rename
142 db.exec('DROP TABLE rooms');
143 db.exec('ALTER TABLE rooms_new RENAME TO rooms');
144
145 db.exec('PRAGMA foreign_keys = ON');
146 db.exec('PRAGMA user_version = 2');
147 db.exec('COMMIT');
148
149 console.log('✅ Migration from v1 to v2 complete');
150 } catch (err) {
151 db.exec('ROLLBACK');
152 console.error('❌ Migration failed:', err);
153 throw err;
154 }
155} else if (currentVersion === 2) {
156 console.log('🔄 Migrating database schema from version 2 to 3...');
157
158 try {
159 db.exec('BEGIN');
160
161 // Add title column to rooms table
162 db.exec('ALTER TABLE rooms ADD COLUMN title TEXT');
163
164 db.exec('PRAGMA user_version = 3');
165 db.exec('COMMIT');
166
167 console.log('✅ Migration from v2 to v3 complete');
168 } catch (err) {
169 db.exec('ROLLBACK');
170 console.error('❌ Migration failed:', err);
171 throw err;
172 }
173} else {
174 // Plant the schema if soil is fresh (for new databases)
175 db.exec(`
176 CREATE TABLE IF NOT EXISTS rooms (
177 code TEXT PRIMARY KEY NOT NULL,
178 created_at INTEGER DEFAULT (unixepoch()),
179 title TEXT
180 );
181
182 CREATE TABLE IF NOT EXISTS items (
183 id TEXT PRIMARY KEY NOT NULL,
184 room_code TEXT NOT NULL,
185 text TEXT NOT NULL CHECK(LENGTH(text) BETWEEN 1 AND 200),
186 created_at INTEGER DEFAULT (unixepoch()),
187 FOREIGN KEY (room_code) REFERENCES rooms(code) ON DELETE CASCADE
188 );
189
190 CREATE TABLE IF NOT EXISTS votes (
191 item_id TEXT NOT NULL,
192 user_id TEXT NOT NULL,
193 vote_type TEXT NOT NULL CHECK(vote_type IN ('up', 'down', 'veto')),
194 created_at INTEGER DEFAULT (unixepoch()),
195 PRIMARY KEY (item_id, user_id),
196 FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
197 );
198 `);
199}
200
201const clients = new Map<string, Set<WebSocket>>();
202
203function generateRoomId(): string {
204 return crypto.randomUUID();
205}
206
207function broadcast(roomCode: string, message: unknown, except?: WebSocket) {
208 const room = clients.get(roomCode);
209 if (!room) return;
210
211 const payload = JSON.stringify(message);
212 for (const client of room) {
213 if (client !== except && client.readyState === WebSocket.OPEN) {
214 client.send(payload);
215 }
216 }
217}
218
219// Validation constants
220const MAX_ITEM_TEXT_LEN = 200;
221const MAX_BULK_ITEMS = 100;
222const MAX_WS_MESSAGE_BYTES = 32768;
223
224// Validation helpers
225function safeParseMessage(ws: WebSocket, raw: string): unknown | null {
226 if (raw.length > MAX_WS_MESSAGE_BYTES) {
227 console.warn(`Message too large: ${raw.length} bytes`);
228 ws.close(1009, 'Message too large');
229 return null;
230 }
231
232 try {
233 return JSON.parse(raw);
234 } catch (err) {
235 console.warn('Invalid JSON:', err);
236 return null;
237 }
238}
239
240function sanitizeItemText(s: string): string | null {
241 // Trim and collapse internal CRLF to \n
242 const cleaned = s.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
243
244 if (cleaned.length < 1 || cleaned.length > MAX_ITEM_TEXT_LEN) {
245 return null;
246 }
247
248 return cleaned;
249}
250
251function isValidVoteType(x: unknown): x is 'up' | 'down' | 'veto' {
252 return x === 'up' || x === 'down' || x === 'veto';
253}
254
255function itemBelongsToRoom(itemId: string, roomCode: string): boolean {
256 const result = db.prepare('SELECT 1 FROM items WHERE id = ? AND room_code = ?').get(itemId, roomCode);
257 return result !== undefined;
258}
259
260function getState(roomCode: string) {
261 const room = db.prepare('SELECT title FROM rooms WHERE code = ?').get(roomCode) as { title: string | null } | undefined;
262 const roomTitle = room?.title || null;
263
264 const items = db.prepare(`
265 SELECT i.id, i.text, i.created_at,
266 GROUP_CONCAT(v.user_id || ':' || v.vote_type) as votes
267 FROM items i
268 LEFT JOIN votes v ON i.id = v.item_id
269 WHERE i.room_code = ?
270 GROUP BY i.id
271 ORDER BY i.created_at
272 `).all(roomCode) as Array<{id: string, text: string, created_at: number, votes: string | null}>;
273
274 return {
275 roomTitle,
276 items: items.map(item => ({
277 id: item.id,
278 text: item.text,
279 votes: item.votes ? Object.fromEntries(
280 item.votes.split(',').map(v => {
281 const [userId, voteType] = v.split(':');
282 return [userId, voteType];
283 })
284 ) : {}
285 }))
286 };
287}
288
289function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
290 // Add to room
291 if (!clients.has(roomCode)) {
292 clients.set(roomCode, new Set());
293 }
294 clients.get(roomCode)!.add(ws);
295
296 ws.onopen = () => {
297 // Send current state once connection is open
298 const state = getState(roomCode);
299 ws.send(JSON.stringify({
300 type: 'state',
301 items: state.items,
302 roomTitle: state.roomTitle,
303 userId
304 }));
305 };
306
307 ws.onmessage = (event) => {
308 const msg = safeParseMessage(ws, event.data);
309 if (!msg || typeof msg !== 'object') return;
310
311 try {
312 switch ((msg as any).type) {
313 case 'add_items': {
314 const rawItems = (msg as any).items;
315 if (!Array.isArray(rawItems)) {
316 console.warn('add_items: items is not an array');
317 return;
318 }
319
320 if (rawItems.length < 1 || rawItems.length > MAX_BULK_ITEMS) {
321 console.warn(`add_items: invalid count ${rawItems.length}`);
322 return;
323 }
324
325 const items = rawItems
326 .map((text: unknown) => {
327 if (typeof text !== 'string') return null;
328 const sanitized = sanitizeItemText(text);
329 return sanitized ? { id: crypto.randomUUID(), text: sanitized } : null;
330 })
331 .filter((item): item is { id: string; text: string } => item !== null);
332
333 if (items.length === 0) {
334 console.warn('add_items: no valid items after sanitization');
335 return;
336 }
337
338 try {
339 db.exec('BEGIN IMMEDIATE');
340 const stmt = db.prepare('INSERT INTO items (id, room_code, text) VALUES (?, ?, ?)');
341 for (const item of items) {
342 stmt.run(item.id, roomCode, item.text);
343 }
344 db.exec('COMMIT');
345 } catch (err) {
346 db.exec('ROLLBACK');
347 console.error('add_items transaction failed:', err);
348 return;
349 }
350
351 broadcast(roomCode, {
352 type: 'items_added',
353 items: items.map(i => ({ ...i, votes: {} }))
354 });
355 break;
356 }
357
358 case 'vote': {
359 const { itemId, voteType } = msg as any;
360
361 if (typeof itemId !== 'string') {
362 console.warn('vote: itemId is not a string');
363 return;
364 }
365
366 if (!isValidVoteType(voteType)) {
367 console.warn(`vote: invalid voteType ${voteType}`);
368 return;
369 }
370
371 if (!itemBelongsToRoom(itemId, roomCode)) {
372 console.warn(`vote: item ${itemId} does not belong to room ${roomCode}`);
373 return;
374 }
375
376 db.prepare(`
377 INSERT INTO votes (item_id, user_id, vote_type)
378 VALUES (?, ?, ?)
379 ON CONFLICT (item_id, user_id)
380 DO UPDATE SET vote_type = excluded.vote_type
381 `).run(itemId, userId, voteType);
382
383 broadcast(roomCode, {
384 type: 'vote_changed',
385 itemId,
386 userId,
387 voteType
388 });
389 break;
390 }
391
392 case 'unvote': {
393 const { itemId } = msg as any;
394
395 if (typeof itemId !== 'string') {
396 console.warn('unvote: itemId is not a string');
397 return;
398 }
399
400 if (!itemBelongsToRoom(itemId, roomCode)) {
401 console.warn(`unvote: item ${itemId} does not belong to room ${roomCode}`);
402 return;
403 }
404
405 db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?')
406 .run(itemId, userId);
407
408 broadcast(roomCode, {
409 type: 'vote_removed',
410 itemId,
411 userId
412 });
413 break;
414 }
415
416 case 'edit_item': {
417 const { itemId, text } = msg as any;
418
419 if (typeof itemId !== 'string') {
420 console.warn('edit_item: itemId is not a string');
421 return;
422 }
423
424 if (typeof text !== 'string') {
425 console.warn('edit_item: text is not a string');
426 return;
427 }
428
429 const sanitized = sanitizeItemText(text);
430 if (!sanitized) {
431 console.warn('edit_item: text failed sanitization');
432 return;
433 }
434
435 if (!itemBelongsToRoom(itemId, roomCode)) {
436 console.warn(`edit_item: item ${itemId} does not belong to room ${roomCode}`);
437 return;
438 }
439
440 db.prepare('UPDATE items SET text = ? WHERE id = ?')
441 .run(sanitized, itemId);
442
443 broadcast(roomCode, {
444 type: 'item_edited',
445 itemId,
446 text: sanitized
447 });
448 break;
449 }
450
451 case 'delete_item': {
452 const { itemId } = msg as any;
453
454 if (typeof itemId !== 'string') {
455 console.warn('delete_item: itemId is not a string');
456 return;
457 }
458
459 if (!itemBelongsToRoom(itemId, roomCode)) {
460 console.warn(`delete_item: item ${itemId} does not belong to room ${roomCode}`);
461 return;
462 }
463
464 // Delete votes first (foreign key constraint)
465 db.prepare('DELETE FROM votes WHERE item_id = ?').run(itemId);
466 // Delete the item
467 db.prepare('DELETE FROM items WHERE id = ?').run(itemId);
468
469 broadcast(roomCode, {
470 type: 'item_deleted',
471 itemId
472 });
473 break;
474 }
475
476 case 'reset_votes': {
477 // Delete all votes for all items in this room
478 db.prepare(`
479 DELETE FROM votes
480 WHERE item_id IN (
481 SELECT id FROM items WHERE room_code = ?
482 )
483 `).run(roomCode);
484
485 broadcast(roomCode, {
486 type: 'votes_reset'
487 });
488 break;
489 }
490
491 case 'set_title': {
492 const { title } = msg as any;
493
494 let sanitized: string | null = null;
495 if (title !== null && title !== undefined) {
496 if (typeof title !== 'string') {
497 console.warn('set_title: title is not a string');
498 return;
499 }
500 sanitized = sanitizeItemText(title);
501 if (!sanitized) {
502 console.warn('set_title: title failed sanitization');
503 return;
504 }
505 }
506
507 db.prepare('UPDATE rooms SET title = ? WHERE code = ?')
508 .run(sanitized, roomCode);
509
510 broadcast(roomCode, {
511 type: 'title_changed',
512 title: sanitized
513 });
514 break;
515 }
516 }
517 } catch (err) {
518 console.error('Message handling error:', err);
519 }
520 };
521
522 ws.onclose = () => {
523 const room = clients.get(roomCode);
524 if (room) {
525 room.delete(ws);
526 if (room.size === 0) {
527 clients.delete(roomCode);
528 }
529 }
530 };
531}
532
533serve(async (req) => {
534 const url = new URL(req.url);
535
536 // Serve static files
537 if (url.pathname === "/" || url.pathname === "/index.html") {
538 const html = await Deno.readTextFile("./static/index.html");
539 return new Response(html, { headers: { "content-type": "text/html" } });
540 }
541 if (url.pathname === "/style.css") {
542 const css = await Deno.readTextFile("./static/style.css");
543 return new Response(css, { headers: { "content-type": "text/css" } });
544 }
545 if (url.pathname === "/app.js") {
546 const js = await Deno.readTextFile("./static/app.js");
547 return new Response(js, { headers: { "content-type": "application/javascript" } });
548 }
549 if (url.pathname.startsWith("/icons/") && url.pathname.endsWith(".svg")) {
550 const iconName = url.pathname.slice(7); // Remove "/icons/"
551 try {
552 const svg = await Deno.readTextFile(`./static/icons/${iconName}`);
553 return new Response(svg, { headers: { "content-type": "image/svg+xml" } });
554 } catch {
555 return new Response("Not found", { status: 404 });
556 }
557 }
558
559 // Create new room
560 if (url.pathname === "/api/create") {
561 const code = generateRoomId();
562 db.prepare('INSERT INTO rooms (code) VALUES (?)').run(code);
563 return Response.json({ code });
564 }
565
566 // WebSocket connection
567 if (url.pathname === "/ws") {
568 const roomCode = url.searchParams.get("room");
569 const userId = url.searchParams.get("user") || crypto.randomUUID();
570
571 if (!roomCode) {
572 return new Response("Missing room code", { status: 400 });
573 }
574
575 // Verify room exists
576 const roomExists = db.prepare('SELECT code FROM rooms WHERE code = ?').get(roomCode);
577 if (!roomExists) {
578 return new Response("Room not found", { status: 404 });
579 }
580
581 const upgrade = req.headers.get("upgrade") || "";
582 if (upgrade.toLowerCase() !== "websocket") {
583 return new Response("Expected websocket", { status: 426 });
584 }
585
586 const { socket, response } = Deno.upgradeWebSocket(req);
587 handleWebSocket(socket, roomCode, userId);
588 return response;
589 }
590
591 return new Response("Not found", { status: 404 });
592}, { port: 8294 });
593
594console.log("🌿 Sift ready at http://localhost:8294");