Initial Commit

This commit is contained in:
2026-05-02 05:49:09 +00:00
commit fbe5535087
117 changed files with 18732 additions and 0 deletions

2004
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
api/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "api",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"better-sqlite3": "^12.9.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"tweetnacl": "^1.0.3",
"uuid": "^14.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}

17
api/src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface AppConfig {
port: number;
dbPath: string;
timestampWindowMs: number;
nonceCleanupIntervalMs: number;
corsOrigin: string;
}
export function createConfig(): AppConfig {
return {
port: parseInt(process.env.PORT || '3000', 10),
dbPath: process.env.DB_PATH || './syncpad.db',
timestampWindowMs: 30000,
nonceCleanupIntervalMs: 60000,
corsOrigin: process.env.CORS_ORIGIN || '*',
};
}

23
api/src/container.ts Normal file
View File

@@ -0,0 +1,23 @@
type Factory<T> = (c: Container) => T;
export class Container {
private registry = new Map<string, Factory<any>>();
private instances = new Map<string, any>();
register<T>(key: string, factory: Factory<T>): void {
this.registry.set(key, factory);
}
resolve<T>(key: string): T {
if (this.instances.has(key)) {
return this.instances.get(key) as T;
}
const factory = this.registry.get(key);
if (!factory) {
throw new Error(`No registration found for "${key}"`);
}
const instance = factory(this);
this.instances.set(key, instance);
return instance as T;
}
}

49
api/src/database.ts Normal file
View File

@@ -0,0 +1,49 @@
import Database from 'better-sqlite3';
import { AppConfig } from './config';
export function createDatabase(config: AppConfig): Database.Database {
const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return db;
}
export function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
encrypted_content TEXT NOT NULL,
encrypted_title TEXT NOT NULL,
iv TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_public_key ON notes(public_key);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at);
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT NOT NULL,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (nonce, public_key, timestamp)
);
CREATE INDEX IF NOT EXISTS idx_nonces_created_at ON nonces(created_at);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
from_public_key TEXT NOT NULL,
to_public_key TEXT NOT NULL,
encrypted_group_key TEXT NOT NULL,
iv TEXT NOT NULL,
note_id TEXT,
note_title TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invitations_to ON invitations(to_public_key);
`);
}

63
api/src/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import express from 'express';
import cors from 'cors';
import { createConfig } from './config';
import { Container } from './container';
import { createDatabase, runMigrations } from './database';
import { NoteRepository } from './repositories/noteRepository';
import { NonceRepository } from './repositories/nonceRepository';
import { InvitationRepository } from './repositories/invitationRepository';
import { NonceService } from './services/nonceService';
import { AuthService } from './services/authService';
import { NoteService } from './services/noteService';
import { SSEService } from './services/sseService';
import { createNotesRouter } from './routes/notes';
import { createSSERouter } from './routes/sse';
import { createInvitationsRouter } from './routes/invitations';
const config = createConfig();
const db = createDatabase(config);
runMigrations(db);
const container = new Container();
container.register('config', () => config);
container.register('db', () => db);
container.register('noteRepository', (c) => new NoteRepository(c.resolve('db')));
container.register('nonceRepository', (c) => new NonceRepository(c.resolve('db')));
container.register('invitationRepository', (c) => new InvitationRepository(c.resolve('db')));
container.register('nonceService', (c) => new NonceService(c.resolve('nonceRepository'), c.resolve('config')));
container.register('authService', (c) => new AuthService(c.resolve('nonceService'), c.resolve('config')));
container.register('noteService', (c) => new NoteService(c.resolve('noteRepository')));
container.register('sseService', () => new SSEService());
const nonceService = container.resolve<NonceService>('nonceService');
nonceService.startCleanupTimer();
const app = express();
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ verify: (_req, _res, buf) => { (buf as any).rawBody = buf; } }));
app.use('/api/notes/sync', createSSERouter(
container.resolve('sseService'),
container.resolve('authService')
));
app.use('/api/notes', createNotesRouter(
container.resolve('noteService'),
container.resolve('sseService'),
container.resolve('authService')
));
app.use('/api/invitations', createInvitationsRouter(
container.resolve('invitationRepository'),
container.resolve('sseService'),
container.resolve('authService')
));
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.listen(config.port, () => {
console.log(`SyncPad API listening on port ${config.port}`);
});
export { app };

View File

@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/authService';
declare global {
namespace Express {
interface Request {
publicKey?: string;
}
}
}
export function authMiddleware(authService: AuthService) {
return (req: Request, res: Response, next: NextFunction): void => {
const publicKey = req.headers['x-publickey'] as string | undefined;
const signature = req.headers['x-signature'] as string | undefined;
const nonce = req.headers['x-nonce'] as string | undefined;
const timestampStr = req.headers['x-timestamp'] as string | undefined;
if (!publicKey || !signature || !nonce || !timestampStr) {
res.status(401).json({ error: 'Missing authentication headers' });
return;
}
const timestamp = parseInt(timestampStr, 10);
if (isNaN(timestamp)) {
res.status(401).json({ error: 'Invalid timestamp' });
return;
}
const body = ['GET', 'DELETE'].includes(req.method) ? '' : JSON.stringify(req.body);
const result = authService.verifyRequest(publicKey, signature, timestamp, nonce, body);
if (!result.valid) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
req.publicKey = publicKey;
next();
};
}

View File

@@ -0,0 +1,37 @@
import Database from 'better-sqlite3'
import { Invitation } from '../types'
import { v4 as uuidv4 } from 'uuid'
export class InvitationRepository {
constructor(private db: Database.Database) {}
findByToPublicKey(publicKey: string): Invitation[] {
return this.db.prepare(
'SELECT * FROM invitations WHERE to_public_key = ? ORDER BY created_at DESC'
).all(publicKey) as Invitation[]
}
create(
fromPublicKey: string,
toPublicKey: string,
encryptedGroupKey: string,
iv: string,
noteId?: string,
noteTitle?: string
): Invitation {
const id = uuidv4()
const createdAt = Date.now()
this.db.prepare(
`INSERT INTO invitations (id, from_public_key, to_public_key, encrypted_group_key, iv, note_id, note_title, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, fromPublicKey, toPublicKey, encryptedGroupKey, iv, noteId ?? null, noteTitle ?? null, createdAt)
return { id, from_public_key: fromPublicKey, to_public_key: toPublicKey, encrypted_group_key: encryptedGroupKey, iv, note_id: noteId, note_title: noteTitle, created_at: createdAt }
}
delete(id: string, publicKey: string): boolean {
const result = this.db.prepare(
'DELETE FROM invitations WHERE id = ? AND to_public_key = ?'
).run(id, publicKey)
return result.changes > 0
}
}

View File

@@ -0,0 +1,25 @@
import Database from 'better-sqlite3';
export class NonceRepository {
constructor(private db: Database.Database) {}
exists(nonce: string, publicKey: string, timestamp: number): boolean {
const row = this.db.prepare(
'SELECT 1 FROM nonces WHERE nonce = ? AND public_key = ? AND timestamp = ?'
).get(nonce, publicKey, timestamp);
return !!row;
}
create(nonce: string, publicKey: string, timestamp: number): void {
this.db.prepare(
'INSERT INTO nonces (nonce, public_key, timestamp, created_at) VALUES (?, ?, ?, ?)'
).run(nonce, publicKey, timestamp, Date.now());
}
deleteOlderThan(createdAt: number): number {
const result = this.db.prepare(
'DELETE FROM nonces WHERE created_at < ?'
).run(createdAt);
return result.changes;
}
}

View File

@@ -0,0 +1,46 @@
import Database from 'better-sqlite3';
import { Note, CreateNoteRequest, UpdateNoteRequest } from '../types';
export class NoteRepository {
constructor(private db: Database.Database) {}
findByPublicKey(publicKey: string): Note[] {
return this.db.prepare(
'SELECT * FROM notes WHERE public_key = ? ORDER BY updated_at DESC'
).all(publicKey) as Note[];
}
findById(id: string): Note | undefined {
return this.db.prepare(
'SELECT * FROM notes WHERE id = ?'
).get(id) as Note | undefined;
}
findByIdAndPublicKey(id: string, publicKey: string): Note | undefined {
return this.db.prepare(
'SELECT * FROM notes WHERE id = ? AND public_key = ?'
).get(id, publicKey) as Note | undefined;
}
create(note: Note): void {
this.db.prepare(
'INSERT INTO notes (id, public_key, encrypted_content, encrypted_title, iv, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(note.id, note.public_key, note.encrypted_content, note.encrypted_title, note.iv, note.created_at, note.updated_at);
}
update(id: string, publicKey: string, data: UpdateNoteRequest, updatedAt: number): Note | undefined {
const result = this.db.prepare(
'UPDATE notes SET encrypted_content = ?, encrypted_title = ?, iv = ?, updated_at = ? WHERE id = ? AND public_key = ?'
).run(data.encrypted_content, data.encrypted_title, data.iv, updatedAt, id, publicKey);
if (result.changes === 0) return undefined;
return this.findById(id);
}
delete(id: string, publicKey: string): boolean {
const result = this.db.prepare(
'DELETE FROM notes WHERE id = ? AND public_key = ?'
).run(id, publicKey);
return result.changes > 0;
}
}

View File

@@ -0,0 +1,55 @@
import { Router, Request, Response } from 'express'
import { InvitationRepository } from '../repositories/invitationRepository'
import { SSEService } from '../services/sseService'
import { AuthService } from '../services/authService'
import { authMiddleware } from '../middleware/auth'
import { CreateInvitationRequest } from '../types'
export function createInvitationsRouter(
invitationRepo: InvitationRepository,
sseService: SSEService,
authService: AuthService
): Router {
const router = Router()
const auth = authMiddleware(authService)
router.get('/', auth, (req: Request, res: Response) => {
const invitations = invitationRepo.findByToPublicKey(req.publicKey!)
res.json({ invitations })
})
router.post('/', auth, (req: Request, res: Response) => {
const data = req.body as CreateInvitationRequest
if (!data.toPublicKey) {
res.status(400).json({ error: 'Missing toPublicKey' })
return
}
const invitation = invitationRepo.create(
req.publicKey!,
data.toPublicKey,
data.encryptedGroupKey ?? '',
data.iv ?? '',
data.noteId,
data.noteTitle
)
sseService.broadcastToPublicKey(data.toPublicKey, {
type: 'invitation_received',
data: invitation,
})
res.status(201).json({ invitation })
})
router.delete('/:id', auth, (req: Request, res: Response) => {
const deleted = invitationRepo.delete(req.params.id as string, req.publicKey!)
if (!deleted) {
res.status(404).json({ error: 'Invitation not found' })
return
}
res.json({ success: true })
})
return router
}

68
api/src/routes/notes.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Router, Request, Response } from 'express';
import { NoteService } from '../services/noteService';
import { SSEService } from '../services/sseService';
import { authMiddleware } from '../middleware/auth';
import { AuthService } from '../services/authService';
import { CreateNoteRequest, UpdateNoteRequest } from '../types';
export function createNotesRouter(
noteService: NoteService,
sseService: SSEService,
authService: AuthService
): Router {
const router = Router();
const auth = authMiddleware(authService);
router.get('/', auth, (req: Request, res: Response) => {
const notes = noteService.getNotes(req.publicKey!);
res.json({ notes });
});
router.post('/', auth, (req: Request, res: Response) => {
const data = req.body as CreateNoteRequest;
if (!data.id || !data.encrypted_content || !data.encrypted_title || !data.iv) {
res.status(400).json({ error: 'Missing required fields' });
return;
}
const note = noteService.createNote(req.publicKey!, data);
if (!note) {
res.status(409).json({ error: 'Note with this ID already exists' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_created', data: note });
res.status(201).json({ note });
});
router.put('/:id', auth, (req: Request, res: Response) => {
const data = req.body as UpdateNoteRequest;
if (!data.encrypted_content || !data.encrypted_title || !data.iv) {
res.status(400).json({ error: 'Missing required fields' });
return;
}
const note = noteService.updateNote(req.params.id as string, req.publicKey!, data);
if (!note) {
res.status(404).json({ error: 'Note not found' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_updated', data: note });
res.json({ note });
});
router.delete('/:id', auth, (req: Request, res: Response) => {
const id = req.params.id as string;
const deleted = noteService.deleteNote(id, req.publicKey!);
if (!deleted) {
res.status(404).json({ error: 'Note not found' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_deleted', data: { id } });
res.json({ success: true });
});
return router;
}

39
api/src/routes/sse.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Router, Request, Response } from 'express';
import { SSEService } from '../services/sseService';
import { AuthService } from '../services/authService';
export function createSSERouter(
sseService: SSEService,
authService: AuthService
): Router {
const router = Router();
router.get('/', (req: Request, res: Response) => {
const publicKey = req.query.publickey as string | undefined;
const signature = req.query.signature as string | undefined;
const nonce = req.query.nonce as string | undefined;
const timestampStr = req.query.timestamp as string | undefined;
if (!publicKey || !signature || !nonce || !timestampStr) {
res.status(401).json({ error: 'Missing authentication parameters' });
return;
}
const timestamp = parseInt(timestampStr, 10);
if (isNaN(timestamp)) {
res.status(401).json({ error: 'Invalid timestamp' });
return;
}
const result = authService.verifyRequest(publicKey, signature, timestamp, nonce, '');
if (!result.valid) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
sseService.addClient(publicKey, res);
});
return router;
}

View File

@@ -0,0 +1,54 @@
import nacl from 'tweetnacl';
import { NonceService } from './nonceService';
import { AppConfig } from '../config';
export class AuthService {
constructor(
private nonceService: NonceService,
private config: AppConfig
) {}
verifyRequest(
publicKeyB64: string,
signatureB64: string,
timestamp: number,
nonce: string,
body: string
): { valid: boolean; error?: string } {
const now = Date.now();
if (Math.abs(now - timestamp) > this.config.timestampWindowMs) {
return { valid: false, error: 'Timestamp expired or invalid' };
}
if (!this.nonceService.validateAndStore(nonce, publicKeyB64, timestamp)) {
return { valid: false, error: 'Nonce already used' };
}
let publicKey: Uint8Array;
let signature: Uint8Array;
try {
publicKey = base64ToUint8Array(publicKeyB64);
signature = base64ToUint8Array(signatureB64);
} catch {
return { valid: false, error: 'Invalid key format' };
}
if (publicKey.length !== nacl.sign.publicKeyLength) {
return { valid: false, error: 'Invalid public key length' };
}
const message = `${timestamp}:${body}`;
const messageBytes = new TextEncoder().encode(message);
const isValid = nacl.sign.detached.verify(messageBytes, signature, publicKey);
if (!isValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
}
function base64ToUint8Array(b64: string): Uint8Array {
return Uint8Array.from(Buffer.from(b64, 'base64'));
}

View File

@@ -0,0 +1,28 @@
import { NonceRepository } from '../repositories/nonceRepository';
import { AppConfig } from '../config';
export class NonceService {
constructor(
private nonceRepo: NonceRepository,
private config: AppConfig
) {}
validateAndStore(nonce: string, publicKey: string, timestamp: number): boolean {
if (this.nonceRepo.exists(nonce, publicKey, timestamp)) {
return false;
}
this.nonceRepo.create(nonce, publicKey, timestamp);
return true;
}
cleanupOldNonces(): number {
const cutoff = Date.now() - this.config.timestampWindowMs * 2;
return this.nonceRepo.deleteOlderThan(cutoff);
}
startCleanupTimer(): NodeJS.Timeout {
return setInterval(() => {
this.cleanupOldNonces();
}, this.config.nonceCleanupIntervalMs);
}
}

View File

@@ -0,0 +1,40 @@
import { NoteRepository } from '../repositories/noteRepository';
import { Note, CreateNoteRequest, UpdateNoteRequest } from '../types';
export class NoteService {
constructor(private noteRepo: NoteRepository) {}
getNotes(publicKey: string): Note[] {
return this.noteRepo.findByPublicKey(publicKey);
}
getNote(id: string, publicKey: string): Note | undefined {
return this.noteRepo.findByIdAndPublicKey(id, publicKey);
}
createNote(publicKey: string, data: CreateNoteRequest): Note | null {
const existing = this.noteRepo.findById(data.id);
if (existing) return null;
const now = Date.now();
const note: Note = {
id: data.id,
public_key: publicKey,
encrypted_content: data.encrypted_content,
encrypted_title: data.encrypted_title,
iv: data.iv,
created_at: now,
updated_at: now,
};
this.noteRepo.create(note);
return note;
}
updateNote(id: string, publicKey: string, data: UpdateNoteRequest): Note | undefined {
return this.noteRepo.update(id, publicKey, data, Date.now());
}
deleteNote(id: string, publicKey: string): boolean {
return this.noteRepo.delete(id, publicKey);
}
}

View File

@@ -0,0 +1,47 @@
import { Response } from 'express';
import { SSEClient, SSEMessage } from '../types';
import { v4 as uuidv4 } from 'uuid';
export class SSEService {
private clients: Map<string, SSEClient> = new Map();
addClient(publicKey: string, res: Response): string {
const id = uuidv4();
const client: SSEClient = { id, public_key: publicKey, res };
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
res.on('close', () => {
this.clients.delete(id);
});
this.clients.set(id, client);
return id;
}
removeClient(id: string): void {
const client = this.clients.get(id);
if (client) {
client.res.end();
this.clients.delete(id);
}
}
broadcastToPublicKey(publicKey: string, message: SSEMessage): void {
for (const client of this.clients.values()) {
if (client.public_key === publicKey) {
try {
client.res.write(`event: ${message.type}\ndata: ${JSON.stringify(message.data)}\n\n`);
} catch {
this.clients.delete(client.id);
}
}
}
}
}

52
api/src/types.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface Note {
id: string;
public_key: string;
encrypted_content: string;
encrypted_title: string;
iv: string;
created_at: number;
updated_at: number;
}
export interface CreateNoteRequest {
id: string;
encrypted_content: string;
encrypted_title: string;
iv: string;
}
export interface UpdateNoteRequest {
encrypted_content: string;
encrypted_title: string;
iv: string;
}
export interface SSEClient {
id: string;
public_key: string;
res: import('express').Response;
}
export interface SSEMessage {
type: 'note_created' | 'note_updated' | 'note_deleted' | 'invitation_received';
data: Note | { id: string } | Invitation;
}
export interface Invitation {
id: string;
from_public_key: string;
to_public_key: string;
encrypted_group_key: string;
iv: string;
note_id?: string;
note_title?: string;
created_at: number;
}
export interface CreateInvitationRequest {
toPublicKey: string;
encryptedGroupKey?: string;
iv?: string;
noteId?: string;
noteTitle?: string;
}

19
api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}