Files
signal_encryption_poc/todo-service.js
Atridad Lahiji d71062cf13
Some checks failed
Deploy Encrypted Todo App / build-and-push (push) Has been cancelled
:)
2025-06-16 09:16:22 -06:00

433 lines
13 KiB
JavaScript

import { DatabaseSync } from "node:sqlite";
import "dotenv/config";
import { SignalClient } from "./signal-crypto.js";
import { v4 as uuidv4 } from "uuid";
export class EncryptedTodoService {
constructor() {
this.db = new DatabaseSync(process.env.SQLITE_DB_PATH);
this.users = new Map();
// Create tables with new schema
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
userId TEXT PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
todoGroupId TEXT,
userId TEXT,
encrypted TEXT,
createdAt TEXT,
originalText TEXT,
completed INTEGER,
sharedBy TEXT,
originalTodoId TEXT,
FOREIGN KEY(userId) REFERENCES users(userId)
);
CREATE TABLE IF NOT EXISTS todo_groups (
todoGroupId TEXT PRIMARY KEY,
createdBy TEXT,
createdAt TEXT,
FOREIGN KEY(createdBy) REFERENCES users(userId)
);
CREATE TABLE IF NOT EXISTS todo_participants (
todoGroupId TEXT,
userId TEXT,
joinedAt TEXT,
PRIMARY KEY(todoGroupId, userId),
FOREIGN KEY(todoGroupId) REFERENCES todo_groups(todoGroupId),
FOREIGN KEY(userId) REFERENCES users(userId)
);
CREATE TABLE IF NOT EXISTS todo_shares (
todoId TEXT,
sharedWith TEXT,
FOREIGN KEY(todoId) REFERENCES todos(id),
FOREIGN KEY(sharedWith) REFERENCES users(userId)
);
`);
// Migrate existing data to new schema
this.migrateToNewSchema();
// Load existing users from database on startup
this.loadExistingUsers();
}
migrateToNewSchema() {
// Check if we need to migrate by looking for todos without todoGroupId
const oldTodos = this.db
.prepare("SELECT * FROM todos WHERE todoGroupId IS NULL")
.all();
if (oldTodos.length > 0) {
console.log(`Migrating ${oldTodos.length} todos to new schema...`);
for (const todo of oldTodos) {
const todoGroupId = uuidv4();
const now = new Date().toISOString();
// If this is an original todo (not shared)
if (!todo.sharedBy && !todo.originalTodoId) {
// Create todo group
this.db
.prepare(
"INSERT OR IGNORE INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, todo.userId, todo.createdAt || now);
// Add creator as participant
this.db
.prepare(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, todo.userId, todo.createdAt || now);
// Update the todo with todoGroupId
this.db
.prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?")
.run(todoGroupId, todo.id);
// Find and migrate shared copies
const sharedCopies = this.db
.prepare("SELECT * FROM todos WHERE originalTodoId = ?")
.all(todo.id);
for (const sharedTodo of sharedCopies) {
// Add shared user as participant
this.db
.prepare(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, sharedTodo.userId, sharedTodo.createdAt || now);
// Update shared todo with same todoGroupId
this.db
.prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?")
.run(todoGroupId, sharedTodo.id);
}
}
// If this is a shared todo without a group (shouldn't happen after above, but just in case)
else if (todo.originalTodoId && !todo.todoGroupId) {
const originalTodo = this.db
.prepare("SELECT todoGroupId FROM todos WHERE id = ?")
.get(todo.originalTodoId);
if (originalTodo && originalTodo.todoGroupId) {
this.db
.prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?")
.run(originalTodo.todoGroupId, todo.id);
// Add as participant if not already
this.db
.prepare(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
)
.run(
originalTodo.todoGroupId,
todo.userId,
todo.createdAt || now,
);
}
}
}
console.log("Migration completed!");
}
}
loadExistingUsers() {
const existingUsers = this.db.prepare("SELECT userId FROM users").all();
for (const user of existingUsers) {
if (!this.users.has(user.userId)) {
this.users.set(user.userId, SignalClient.create(user.userId));
console.log(`Loaded existing user: ${user.userId}`);
}
}
}
createUser(userId) {
this.db
.prepare("INSERT OR IGNORE INTO users (userId) VALUES (?)")
.run(userId);
if (!this.users.has(userId)) {
this.users.set(userId, SignalClient.create(userId));
}
return { userId };
}
getAllUsers() {
return this.db
.prepare("SELECT userId FROM users")
.all()
.map((u) => u.userId);
}
addTodo(userId, todoText) {
const todoGroupId = uuidv4();
const client = this.users.get(userId);
const todoId = uuidv4();
const encryptedTodo = client.encrypt(todoText);
const now = new Date().toISOString();
// Create the todo group
this.db
.prepare(
"INSERT INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, userId, now);
// Add the creator as a participant
this.db
.prepare(
"INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, userId, now);
// Create the todo entry for the creator
this.db
.prepare(
"INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
todoId,
todoGroupId,
userId,
JSON.stringify(encryptedTodo),
now,
todoText,
0,
);
return todoGroupId;
}
getTodos(userId) {
const client = this.users.get(userId);
if (!client) {
console.error(`No client found for user ${userId}`);
return [];
}
const rows = this.db
.prepare("SELECT * FROM todos WHERE userId = ?")
.all(userId);
const todos = [];
for (const row of rows) {
try {
// Skip todos without todoGroupId (migration didn't work)
if (!row.todoGroupId) {
console.warn(`Todo ${row.id} has no todoGroupId, skipping`);
continue;
}
const decryptedText = client.decrypt(JSON.parse(row.encrypted));
// Get all participants in this todo group
const participants = this.db
.prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?")
.all(row.todoGroupId)
.map((p) => p.userId);
// Get group creator
const groupInfo = this.db
.prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?")
.get(row.todoGroupId);
todos.push({
id: row.todoGroupId, // Use todoGroupId as the primary identifier
text: decryptedText,
createdAt: row.createdAt,
completed: !!row.completed,
participants: participants.length > 0 ? participants : [userId], // Fallback to current user
createdBy: groupInfo?.createdBy || userId, // Fallback to current user
isCreator: (groupInfo?.createdBy || userId) === userId,
});
} catch (e) {
console.error(`Error processing todo ${row.id}:`, e);
}
}
return todos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
getTodo(userId, todoGroupId) {
const client = this.users.get(userId);
const row = this.db
.prepare("SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?")
.get(todoGroupId, userId);
if (!row) throw new Error("Todo not found");
const decryptedText = client.decrypt(JSON.parse(row.encrypted));
// Get all participants in this todo group
const participants = this.db
.prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?")
.all(todoGroupId)
.map((p) => p.userId);
// Get group creator
const groupInfo = this.db
.prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?")
.get(todoGroupId);
return {
id: todoGroupId,
text: decryptedText,
createdAt: row.createdAt,
completed: !!row.completed,
participants: participants,
createdBy: groupInfo?.createdBy,
isCreator: groupInfo?.createdBy === userId,
};
}
updateTodoStatus(userId, todoGroupId, completed) {
console.log(
`updateTodoStatus called: userId=${userId}, todoGroupId=${todoGroupId}, completed=${completed}`,
);
// Get all participants in this todo group
const participants = this.db
.prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?")
.all(todoGroupId)
.map((p) => p.userId);
if (!participants.includes(userId)) {
throw new Error("User is not a participant in this todo group");
}
console.log(`Updating todo for all participants:`, participants);
// Update todos for all participants
for (const participantId of participants) {
this.db
.prepare(
"UPDATE todos SET completed = ? WHERE todoGroupId = ? AND userId = ?",
)
.run(completed ? 1 : 0, todoGroupId, participantId);
}
console.log(`Affected users for todo update:`, participants);
return participants;
}
deleteTodo(userId, todoGroupId) {
// Get all participants in this todo group
const participants = this.db
.prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?")
.all(todoGroupId)
.map((p) => p.userId);
if (!participants.includes(userId)) {
throw new Error("User is not a participant in this todo group");
}
// Check if user is the creator of this todo group
const groupInfo = this.db
.prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?")
.get(todoGroupId);
if (!groupInfo) {
throw new Error("Todo group not found");
}
if (groupInfo.createdBy !== userId) {
throw new Error("Only the creator can delete this todo");
}
console.log(
`Deleting todo group ${todoGroupId} for all participants:`,
participants,
);
// Delete all todo entries for this group
this.db.prepare("DELETE FROM todos WHERE todoGroupId = ?").run(todoGroupId);
// Delete all participants
this.db
.prepare("DELETE FROM todo_participants WHERE todoGroupId = ?")
.run(todoGroupId);
// Delete the group itself
this.db
.prepare("DELETE FROM todo_groups WHERE todoGroupId = ?")
.run(todoGroupId);
return {
deletedIds: [todoGroupId],
affectedUsers: participants,
};
}
shareTodo(userId, todoGroupId, recipientId) {
// Ensure recipient user exists
if (!this.users.has(recipientId)) {
this.users.set(recipientId, SignalClient.create(recipientId));
}
// Check if user is a participant in this todo group
const participants = this.db
.prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?")
.all(todoGroupId)
.map((p) => p.userId);
if (!participants.includes(userId)) {
throw new Error("User is not a participant in this todo group");
}
// Check if user is the creator of this todo group
const groupInfo = this.db
.prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?")
.get(todoGroupId);
if (!groupInfo) {
throw new Error("Todo group not found");
}
if (groupInfo.createdBy !== userId) {
throw new Error("Only the creator can add participants to this todo");
}
if (participants.includes(recipientId)) {
throw new Error("User is already a participant in this todo group");
}
// Get the original text from the sender's todo
const senderTodo = this.db
.prepare("SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?")
.get(todoGroupId, userId);
if (!senderTodo) throw new Error("Todo not found");
const recipientClient = this.users.get(recipientId);
const encryptedForRecipient = recipientClient.encrypt(
senderTodo.originalText,
);
const recipientTodoId = uuidv4();
const now = new Date().toISOString();
// Add recipient as a participant
this.db
.prepare(
"INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
)
.run(todoGroupId, recipientId, now);
// Create todo entry for the recipient
this.db
.prepare(
"INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
recipientTodoId,
todoGroupId,
recipientId,
JSON.stringify(encryptedForRecipient),
now,
senderTodo.originalText,
senderTodo.completed,
);
return todoGroupId;
}
}