Initial Commit
This commit is contained in:
2004
api/package-lock.json
generated
Normal file
2004
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
api/package.json
Normal file
31
api/package.json
Normal 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
17
api/src/config.ts
Normal 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
23
api/src/container.ts
Normal 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
49
api/src/database.ts
Normal 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
63
api/src/index.ts
Normal 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 };
|
||||
42
api/src/middleware/auth.ts
Normal file
42
api/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
37
api/src/repositories/invitationRepository.ts
Normal file
37
api/src/repositories/invitationRepository.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
api/src/repositories/nonceRepository.ts
Normal file
25
api/src/repositories/nonceRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
api/src/repositories/noteRepository.ts
Normal file
46
api/src/repositories/noteRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
55
api/src/routes/invitations.ts
Normal file
55
api/src/routes/invitations.ts
Normal 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
68
api/src/routes/notes.ts
Normal 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
39
api/src/routes/sse.ts
Normal 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;
|
||||
}
|
||||
54
api/src/services/authService.ts
Normal file
54
api/src/services/authService.ts
Normal 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'));
|
||||
}
|
||||
28
api/src/services/nonceService.ts
Normal file
28
api/src/services/nonceService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
api/src/services/noteService.ts
Normal file
40
api/src/services/noteService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
api/src/services/sseService.ts
Normal file
47
api/src/services/sseService.ts
Normal 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
52
api/src/types.ts
Normal 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
19
api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user