diff --git a/.gitignore b/.gitignore index f563f27..f95bbdc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ *.env* -*.db +*.db* *.pem *.crt *.key - +/notes +/cert # Logs logs *.log diff --git a/deno.json b/deno.json index 5795473..68fee05 100644 --- a/deno.json +++ b/deno.json @@ -1,18 +1,20 @@ { "imports": { + "@db/sqlite": "jsr:@db/sqlite@^0.12.0", + "@std/assert": "jsr:@std/assert@^1.0.6", "solid-js": "npm:solid-js@^1.8.23", "solid-primitives/deep": "npm:@solid-primitives/deep@^0.2.9", "d3": "npm:@types/d3" }, "compilerOptions": { "lib": [ - "dom", "dom.iterable" + "dom", "dom.iterable", "deno.ns" ] }, "tasks": { "watch": "deno run --allow-read --allow-write --allow-ffi --allow-run --allow-sys --allow-env --env --node-modules-dir npm:vite build --watch", "buildAndServe": "deno task build && deno task serve", - "serve": "deno run --allow-read=$PWD,'./dist',./cert,/etc/letsencrypt/live/chatmux.com --allow-env --env --unstable-kv --allow-net --watch ./server/server.ts", + "serve": "deno run --allow-read=$PWD,$HOME/Library/Caches/deno/plug,'./dist',./cert,/etc/letsencrypt/live/chatmux.com --allow-env --allow-ffi --env --allow-net --watch ./server/server.ts", "vite": "deno run -A --allow-read=$PWD,'./dist',./cert,/etc/letsencrypt/live/chatmux.com --node-modules-dir npm:vite --host", "build": "deno run -A --node-modules-dir npm:vite build", "preview": "deno run -A --unstable --node-modules-dir npm:vite preview" diff --git a/deno.lock b/deno.lock index c7b407f..511e21b 100644 --- a/deno.lock +++ b/deno.lock @@ -1,21 +1,35 @@ { "version": "4", "specifiers": { + "jsr:@db/sqlite@*": "0.12.0", + "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@denosaurs/plug@1": "1.0.6", "jsr:@oak/commons@1": "1.0.0", "jsr:@oak/oak@17": "17.0.0", - "jsr:@std/assert@1": "1.0.5", + "jsr:@std/assert@*": "1.0.6", + "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@0.221": "0.221.0", + "jsr:@std/assert@1": "1.0.6", + "jsr:@std/assert@^1.0.6": "1.0.6", "jsr:@std/bytes@1": "1.0.2", "jsr:@std/bytes@^1.0.2": "1.0.2", "jsr:@std/crypto@1": "1.0.3", + "jsr:@std/encoding@0.221": "0.221.0", "jsr:@std/encoding@1": "1.0.5", "jsr:@std/encoding@^1.0.5": "1.0.5", + "jsr:@std/fmt@0.221": "0.221.0", + "jsr:@std/fs@0.221": "0.221.0", "jsr:@std/http@1": "1.0.6", + "jsr:@std/internal@^1.0.4": "1.0.4", "jsr:@std/io@0.224": "0.224.8", "jsr:@std/media-types@1": "1.0.3", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@0.221": "0.221.0", "jsr:@std/path@1": "1.0.6", "jsr:@std/ulid@*": "1.0.0", "npm:@solid-primitives/deep@~0.2.9": "0.2.9_solid-js@1.8.23__seroval@1.1.1", "npm:@types/d3@*": "7.4.3", + "npm:@types/node@*": "22.5.4", "npm:path-to-regexp@6.2.1": "6.2.1", "npm:solid-js@^1.8.23": "1.8.23_seroval@1.1.1", "npm:vite-plugin-solid@^2.3.0": "2.10.2_solid-js@1.8.23__seroval@1.1.1_vite@5.4.7_@babel+core@7.25.2", @@ -24,10 +38,26 @@ "npm:vite@^5.4.9": "5.4.9" }, "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@0.217" + ] + }, + "@denosaurs/plug@1.0.6": { + "integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7", + "dependencies": [ + "jsr:@std/encoding@0.221", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@0.221" + ] + }, "@oak/commons@1.0.0": { "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", "dependencies": [ - "jsr:@std/assert", + "jsr:@std/assert@1", "jsr:@std/bytes@1", "jsr:@std/crypto", "jsr:@std/encoding@1", @@ -39,34 +69,62 @@ "integrity": "1be33e585080d8ce5093963469a05ed265f1d5f139421c9b0fda8419a533ebee", "dependencies": [ "jsr:@oak/commons", - "jsr:@std/assert", + "jsr:@std/assert@1", "jsr:@std/bytes@1", "jsr:@std/crypto", "jsr:@std/http", "jsr:@std/io", "jsr:@std/media-types", - "jsr:@std/path", + "jsr:@std/path@1", "npm:path-to-regexp" ] }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, + "@std/assert@0.221.0": { + "integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a" + }, "@std/assert@1.0.5": { "integrity": "e37da8e4033490ce613eec4ac1d78dba1faf5b02a3f6c573a28f15365b9b440f" }, + "@std/assert@1.0.6": { + "integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207", + "dependencies": [ + "jsr:@std/internal" + ] + }, "@std/bytes@1.0.2": { "integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57" }, "@std/crypto@1.0.3": { "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" }, + "@std/encoding@0.221.0": { + "integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45" + }, "@std/encoding@1.0.5": { "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" }, + "@std/fmt@0.221.0": { + "integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a" + }, + "@std/fs@0.221.0": { + "integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286", + "dependencies": [ + "jsr:@std/assert@0.221", + "jsr:@std/path@0.221" + ] + }, "@std/http@1.0.6": { "integrity": "20a6f3fa4a914fbb19ea96572f2519b656232597092581ed706cd865d842d0d0", "dependencies": [ "jsr:@std/encoding@^1.0.5" ] }, + "@std/internal@1.0.4": { + "integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422" + }, "@std/io@0.224.8": { "integrity": "f525d05d51fd873de6352b9afcf35cab9ab5dc448bf3c20e0c8b521ded9be392", "dependencies": [ @@ -76,6 +134,18 @@ "@std/media-types@1.0.3": { "integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159" }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@0.221.0": { + "integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095", + "dependencies": [ + "jsr:@std/assert@0.221" + ] + }, "@std/path@1.0.6": { "integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed" }, @@ -602,6 +672,12 @@ "@types/geojson@7946.0.14": { "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" }, + "@types/node@22.5.4": { + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dependencies": [ + "undici-types" + ] + }, "ansi-styles@3.2.1": { "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dependencies": [ @@ -830,6 +906,9 @@ "to-fast-properties@2.0.0": { "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" }, + "undici-types@6.19.8": { + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, "update-browserslist-db@1.1.0_browserslist@4.23.3": { "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dependencies": [ @@ -906,6 +985,8 @@ }, "workspace": { "dependencies": [ + "jsr:@db/sqlite@0.12", + "jsr:@std/assert@^1.0.6", "npm:@solid-primitives/deep@~0.2.9", "npm:@types/d3@*", "npm:solid-js@^1.8.23" diff --git a/server/api.ts b/server/api.ts index 350e51b..d5888d8 100644 --- a/server/api.ts +++ b/server/api.ts @@ -1,5 +1,6 @@ -import { Request } from "https://jsr.io/@oak/oak/17.0.0/request.ts"; +import { Request } from "jsr:@oak/oak@17/request"; import { Router } from "jsr:@oak/oak@17/router"; +import * as db from "./db.ts"; export { api } @@ -40,8 +41,9 @@ export type Room = { } export type Identity = { - source?: string, id?: string, + source?: string, + source_id?:string, name?: string, avatar_url?: string } @@ -50,19 +52,12 @@ export type Connection = { id: string, color?: string, text?: string, - status?: string, + status?: string | null, roomId?: string, + kind?: string, identity?: Identity } -type ConnectionLog = { - kind: 'returning' | 'new', - connectionId: string, - os?: string, - ip?: string, - name?: string, -} - export type Update = { connectionId: string, field: keyof Connection, @@ -105,7 +100,7 @@ const updateFunctionByUUID = new Map() -const connectionByUUID = new Map() +const connectionByUUID = db.getConnectionsByUUID() ?? new Map() export function validateConnectionByUUID(uuid: string) { return connectionByUUID.has(uuid) @@ -117,6 +112,7 @@ export async function addConnectionIdentity(uuid: string, identity: Identity) { console.log("addConnectionIdentity", con) + db.persistConnection(uuid, con) notifyAllConnections(sseEvent.update, { connectionId: con.id, field: "identity", @@ -151,14 +147,12 @@ function getUUID(connectionId: string) { } } -function updateConnectionProperty(req: Request, field: keyof Connection, value?: string): Update { +function getConnection(req: Request) { const uuid = req.headers.get(AUTH_TOKEN_HEADER_NAME); if (!uuid) throw new Error(`Missing ${AUTH_TOKEN_HEADER_NAME} header`); const con = connectionByUUID.get(uuid); if (!con) throw new Error(`No connection found for key ${uuid}`); - if (value) con[field] = value - else delete con[field] - return { connectionId: con.id, field, value: value ?? "" } + return {uuid,con}; } function objectFrom(map: Map) { @@ -172,11 +166,12 @@ function objectFrom(map: Map) { function deleteRoom(room: Room) { //kick all users from the room console.log("deleteRoom: kick all users!") - connectionByUUID.forEach((connection, uuid) => { - if (connection.roomId === room.id) { - delete connection.roomId; + connectionByUUID.forEach((con, uuid) => { + if (con.roomId === room.id) { + delete con.roomId; + db.persistConnection(uuid, con) notifyAllConnections(sseEvent.update, { - connectionId: connection.id, + connectionId: con.id, field: "roomId", value: "" }); @@ -184,7 +179,6 @@ function deleteRoom(room: Room) { }); roomByUUID.delete(room.id) - notifyAllConnections(sseEvent.delete_room, room) } @@ -244,6 +238,7 @@ api.post(`/${apiRoute["room/join"]}/:id`, async (ctx) => { } con.roomId = room?.id + db.persistConnection(uuid, con) notifyAllConnections(sseEvent.update, { connectionId: con.id, field: "roomId", @@ -271,6 +266,8 @@ api.post(`/${apiRoute.room}`, async (ctx) => { con.roomId = room.id roomByUUID.set(room.id, room) + //TODO: persist room? + db.persistConnection(uuid, con) notifyAllConnections(sseEvent.new_room, room) notifyAllConnections(sseEvent.update, { connectionId: con.id, @@ -295,6 +292,7 @@ api.delete(`/${apiRoute.room}/:id`, async (ctx) => { //remove the room reference regardless (we're leaving the room) delete con.roomId + db.persistConnection(uuid, con) notifyAllConnections(sseEvent.update, { connectionId: con.id, field: "roomId", @@ -349,8 +347,14 @@ api.post(`/${apiRoute.clear}/:key`, async (ctx) => { api.post(`/${apiRoute.becomeAnonymous}`, async (context) => { // console.log(context.request.method.toUpperCase(), context.request.url.pathname) try { - const update = updateConnectionProperty(context.request, "identity") - notifyAllConnections(sseEvent.update, update) + const {uuid, con} = getConnection(context.request); + delete con.identity + db.persistConnection(uuid, con) + notifyAllConnections(sseEvent.update, { + connectionId: con.id, + field: "identity", + value: "", + }) context.response.status = 200 } catch (err) { console.error(err, context.request) @@ -364,8 +368,14 @@ api.post(`/${apiRoute.setText}`, async (context) => { if (text.length > 123) throw new Error("invalid text") - const update = updateConnectionProperty(context.request, "text", text) - notifyAllConnections(sseEvent.update, update) + const {uuid,con} = getConnection(context.request) + con.text = text + db.persistConnection(uuid, con) + notifyAllConnections(sseEvent.update, { + connectionId: con.id, + field: 'text', + value: text + }) context.response.status = 200 } catch (err) { console.error(err, context.request) @@ -379,8 +389,14 @@ api.post(`/${apiRoute.setColor}`, async (context) => { if (!color.startsWith("#") || color.length > 9) throw new Error("invalid color") - const update = updateConnectionProperty(context.request, "color", color) - notifyAllConnections(sseEvent.update, update) + const {uuid,con} = getConnection(context.request) + con.color = color + db.persistConnection(uuid, con) + notifyAllConnections(sseEvent.update, { + connectionId: con.id, + field: 'color', + value: color + }) context.response.status = 200 } catch (err) { console.error(err, context.request) @@ -413,6 +429,9 @@ api.get(`/${apiRoute.sse}`, async (context) => { } connection.status = "online" + connection.kind = context.request.userAgent.os.name + + db.persistConnection(uuid, connection) updateFunctionByUUID.set(uuid, { isLocal: true, update: (event, value) => { @@ -461,6 +480,7 @@ api.get(`/${apiRoute.sse}`, async (context) => { //we're also leaving the room delete connection.roomId + db.persistConnection(uuid, connection) notifyAllConnections(sseEvent.update, { connectionId: connection.id, field: "roomId", diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..1f54a1d --- /dev/null +++ b/server/db.ts @@ -0,0 +1,163 @@ +import { Database } from "jsr:@db/sqlite"; +import { Connection, type Identity } from "./api.ts"; +import { assertEquals } from "jsr:@std/assert"; + +//const db = new Database("test.db"); + +const db = new Database("data.db"); + +db.prepare(`PRAGMA journal_mode=WAL;`).run() +db.prepare(`PRAGMA foreign_keys = ON;`).run() + +const createTableIdentity = db.prepare(`CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY, + source TEXT, + source_id TEXT, + name TEXT, + avatar_url TEXT);`) + +const createTableConnection = db.prepare(`CREATE TABLE IF NOT EXISTS connection ( + uuid TEXT PRIMARY KEY, + id TEXT NOT NULL, + identityId INTEGER, + color TEXT, + text TEXT, + status TEXT, + roomId TEXT, + kind TEXT, + FOREIGN KEY(identityId) REFERENCES identity(id));`) + +const createTableRoom = db.prepare(`CREATE TABLE IF NOT EXISTS room ( + id TEXT PRIMARY KEY, + ownerId TEXT);`) + +//can't prepare queries before creating the tables they depend upon +createTableIdentity.run() +createTableRoom.run() +createTableConnection.run() + + + +const upsertConnection = db.prepare(` + INSERT + INTO connection (uuid, id, identityId, color, text, status, roomId, kind) + VALUES (:uuid, :id, :identityId, :color, :text, :status, :roomId, :kind) + ON CONFLICT(uuid) + DO UPDATE SET + id = excluded.id, + identityId = excluded.identityId, + color = excluded.color, + text = excluded.text, + status = excluded.status, + roomId = excluded.roomId, + kind = excluded.kind + RETURNING *;` +) + +const upsertIdentity = db.prepare(`INSERT + INTO identity (id, source, source_id, name, avatar_url) + VALUES (:id, :source, :source_id, :name, :avatar_url) + ON CONFLICT(id) + DO UPDATE SET + name = excluded.name, + source = excluded.source, + source_id = excluded.source_id, + avatar_url = excluded.avatar_url + RETURNING *;` +) + +const selectConnections = db.prepare(`SELECT * FROM connection`) +const selectIdentityById = db.prepare(`SELECT * FROM identity WHERE id = :id`) +const selectIdentityBySource = db.prepare( + `SELECT * + FROM identity + WHERE source = :source + AND source_id = :source_id;` +) + +export function persistConnection(uuid: string, con: Connection) { + + let idResult: Identity | undefined = undefined + if (con.identity) { + const cid = con.identity + if (!cid.id && cid.source && cid.source_id) { + //try to reclaim a previously saved identity + idResult = selectIdentityBySource.get(cid.source, cid.source_id) + // console.log('selected', idResult) + } + + if (!idResult) { + idResult = upsertIdentity.get(cid) + // console.log('upserted', idResult) + } + } + + const dbCon = { + uuid, + identityId: idResult?.id || null, + ...con, + } + delete dbCon.identity + + const conResult = upsertConnection.get(dbCon) + console.log('DB UPSERTED', conResult, idResult) +} + +export function getConnectionsByUUID() { + const connections = selectConnections.all() + + const result = new Map() + connections.forEach(c => { + const con = { ...c } + delete con.uuid + delete con.identityId + if (c.identityId) { + const ident = selectIdentityById.get({ id: c.identityId }) + con.identity = ident as Identity + } + removeNullFields(con) + result.set(c.uuid, con as Connection) + }) + + return result +} + +function removeNullFields(obj: any) { + for (const prop in obj) { + if (obj[prop] == null) + delete obj[prop] + else if(typeof obj[prop] === 'object') + removeNullFields(obj[prop]) + } +} + +function test() { + const connectionByUUID = new Map() + + connectionByUUID.set('AAA', { + id: "idAAA", + color: 'colorAAA', + identity: { + // id: '1', + source: 'test', + source_id: '1234', + name: 'dan', + avatar_url: 'http://test.test/test.png' + } + }) + + connectionByUUID.set('XXX', { + status: 'online', + id: "idXXX", + }) + + connectionByUUID.forEach((con, uuid) => persistConnection(uuid, con)) + + const cons = getConnectionsByUUID() + + assertEquals(connectionByUUID, cons) +} + +//test() + +//db.close(); \ No newline at end of file diff --git a/server/github.ts b/server/github.ts index 722e35f..67b9ee7 100644 --- a/server/github.ts +++ b/server/github.ts @@ -73,7 +73,7 @@ github.get(`/oauth`, async (context) => { const { id, name, avatar_url } = json - addConnectionIdentity(uuid, { source: 'github', id, name, avatar_url }) + addConnectionIdentity(uuid, { source: 'github', source_id: id, name, avatar_url }) console.log('GITHUB AUTH SUCCESS', hasValidConnection) context.response.redirect('/')