Spaces:
Running
Running
| const express = require('express'); | |
| const Database = require('better-sqlite3'); | |
| const cors = require('cors'); | |
| const fs = require('fs'); | |
| const app = express(); | |
| // Parse ALLOWED_ORIGINS from Hugging Face secrets (environment variables) | |
| let allowedOrigins = '*'; // Default fallback to allow all if secret is not set | |
| if (process.env.ALLOWED_ORIGINS) { | |
| // Splits a comma-separated string like "https://example.com,https://app.example.com" | |
| allowedOrigins = process.env.ALLOWED_ORIGINS.split(',').map(url => url.trim()); | |
| console.log(`π CORS Restricted to Origins:`, allowedOrigins); | |
| } else { | |
| console.log(`β οΈ CORS is globally accessible (*). Set ALLOWED_ORIGINS secret to secure.`); | |
| } | |
| // CORS Middleware Configuration | |
| app.use(cors({ | |
| origin: allowedOrigins, | |
| methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], | |
| allowedHeaders: ['Content-Type', 'X-User-ID', 'X-User-Email'] | |
| })); | |
| app.use(express.json({ limit: '50mb' })); | |
| // ============================================ | |
| // LOGGING MIDDLEWARE | |
| // ============================================ | |
| const logRequest = (req, res, next) => { | |
| const timestamp = new Date().toISOString(); | |
| const userId = req.headers['x-user-id'] || 'NO_USER_ID'; | |
| const userEmail = req.headers['x-user-email'] || ''; | |
| console.log(`\nπ₯ [${timestamp}] ${req.method} ${req.path}`); | |
| console.log(` π€ User ID: ${userId}${userEmail ? ` (${userEmail})` : ''}`); | |
| console.log(` π Origin: ${req.headers.origin || 'Unknown'}`); | |
| console.log(` π IP: ${req.ip}`); | |
| if (req.method === 'POST' || req.method === 'PATCH') { | |
| console.log(` π¦ Request Body:`, JSON.stringify(req.body, null, 2).substring(0, 500)); | |
| } | |
| if (req.query && Object.keys(req.query).length > 0) { | |
| console.log(` π Query Params:`, req.query); | |
| } | |
| // Store original send function | |
| const originalSend = res.send; | |
| res.send = function(body) { | |
| const responseTime = Date.now() - req.startTime; | |
| console.log(`\nπ€ [${timestamp}] ${req.method} ${req.path} β ${res.statusCode}`); | |
| console.log(` β±οΈ Response Time: ${responseTime}ms`); | |
| if (res.statusCode >= 400) { | |
| console.log(` β Error Response:`, body); | |
| } else { | |
| if (typeof body === 'string') { | |
| try { | |
| const parsed = JSON.parse(body); | |
| console.log(` β Success Response:`, JSON.stringify(parsed, null, 2).substring(0, 300)); | |
| } catch { | |
| console.log(` β Success Response: ${body.substring(0, 100)}...`); | |
| } | |
| } else if (typeof body === 'object') { | |
| console.log(` β Success Response:`, JSON.stringify(body, null, 2).substring(0, 300)); | |
| } | |
| } | |
| originalSend.call(this, body); | |
| }; | |
| req.startTime = Date.now(); | |
| next(); | |
| }; | |
| app.use(logRequest); | |
| // ============================================ | |
| // DATABASE INITIALIZATION | |
| // ============================================ | |
| const path = require('path'); | |
| let databaseDir; | |
| if (fs.existsSync('/data')) { | |
| databaseDir = '/data'; | |
| console.log("β PRODUCTION MODE: Using Persistent Storage at /data"); | |
| } else { | |
| databaseDir = path.join(process.cwd(), "data"); | |
| if (!fs.existsSync(databaseDir)) { | |
| fs.mkdirSync(databaseDir, { recursive: true }); | |
| } | |
| console.log(`β οΈ LOCAL MODE: Using local storage at ${databaseDir}`); | |
| } | |
| // Initialize Database | |
| const dbPath = path.join(databaseDir, 'chat_storage.db'); | |
| console.log(`πΎ Initializing database at: ${dbPath}`); | |
| try { | |
| const db = new Database(dbPath); | |
| console.log('β Database connection established'); | |
| // Database settings | |
| db.pragma('journal_mode = WAL'); | |
| db.pragma('foreign_keys = ON'); | |
| console.log('π§ Database pragmas set'); | |
| // Schema | |
| const initSchema = () => { | |
| const schema = ` | |
| CREATE TABLE IF NOT EXISTS chats ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id TEXT NOT NULL, | |
| title TEXT NOT NULL, | |
| model TEXT DEFAULT 'default', | |
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS messages ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| chat_id INTEGER NOT NULL, | |
| role TEXT CHECK( role IN ('user','assistant') ) NOT NULL, | |
| content TEXT NOT NULL, | |
| model TEXT, | |
| timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| FOREIGN KEY (chat_id) REFERENCES chats (id) ON DELETE CASCADE | |
| ); | |
| CREATE TABLE IF NOT EXISTS chat_workspaces ( | |
| chat_id INTEGER PRIMARY KEY, | |
| content TEXT NOT NULL DEFAULT '', | |
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| FOREIGN KEY (chat_id) REFERENCES chats (id) ON DELETE CASCADE | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_chats_user ON chats(user_id, updated_at DESC); | |
| CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id); | |
| CREATE INDEX IF NOT EXISTS idx_chat_workspaces_updated ON chat_workspaces(updated_at DESC); | |
| /* ββ Ant-Editor Document Storage ββ */ | |
| CREATE TABLE IF NOT EXISTS documents ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id TEXT NOT NULL, | |
| title TEXT NOT NULL DEFAULT 'Untitled Document', | |
| content TEXT NOT NULL DEFAULT '', | |
| language TEXT NOT NULL DEFAULT 'en', | |
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, updated_at DESC); | |
| /* ββ User Identity Tracking ββ */ | |
| /* Maps user_id (UUID from Cloudflare KV) to email for traceability. */ | |
| /* The proxy (catchall.js) verifies the session and sends both headers. */ | |
| CREATE TABLE IF NOT EXISTS users ( | |
| user_id TEXT PRIMARY KEY, | |
| email TEXT NOT NULL DEFAULT '', | |
| first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); | |
| /* ββ Document Sharing ββ */ | |
| /* Maps a random share_token (UUID) to a document ID. */ | |
| /* Anyone with the token can read/write the document β no auth. */ | |
| /* ON DELETE CASCADE: deleting the source document nukes shares. */ | |
| CREATE TABLE IF NOT EXISTS shared_documents ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| share_token TEXT NOT NULL UNIQUE, | |
| document_id INTEGER NOT NULL, | |
| created_by TEXT NOT NULL, | |
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE | |
| ); | |
| CREATE UNIQUE INDEX IF NOT EXISTS idx_shared_token ON shared_documents(share_token); | |
| CREATE INDEX IF NOT EXISTS idx_shared_doc_id ON shared_documents(document_id); | |
| `; | |
| db.exec(schema); | |
| console.log('β Database schema initialized'); | |
| // Log existing data counts | |
| const chatCount = db.prepare('SELECT COUNT(*) as count FROM chats').get().count; | |
| const messageCount = db.prepare('SELECT COUNT(*) as count FROM messages').get().count; | |
| console.log(`π Database Stats: ${chatCount} chats, ${messageCount} messages`); | |
| }; | |
| initSchema(); | |
| // ============================================ | |
| // HELPER FUNCTIONS | |
| // ============================================ | |
| // Middleware to get user ID + canonical email-based identity resolution. | |
| // | |
| // Problem: The same Google account gets a DIFFERENT random UUID in each | |
| // Cloudflare environment (dev vs prod) because each has its own KV store. | |
| // But they all share this single backend. Without resolution, the same | |
| // person's documents are split across multiple user_ids. | |
| // | |
| // Solution: The `users` table tracks the FIRST user_id we ever saw for | |
| // a given email. On subsequent requests, if the email arrives with a | |
| // DIFFERENT user_id (from another environment), we: | |
| // 1. Auto-migrate their documents and chats to the canonical user_id | |
| // 2. Override req.userId to the canonical one | |
| // | |
| // This ensures one email = one identity = one set of documents, always. | |
| const requireUser = (req, res, next) => { | |
| // Accept user ID from header OR query param (for sendBeacon) | |
| const incomingUserId = req.headers['x-user-id'] || req.query.user_id; | |
| if (!incomingUserId || typeof incomingUserId !== 'string') { | |
| console.log(`β Missing or invalid X-User-ID header`); | |
| return res.status(401).json({ error: 'User identity required' }); | |
| } | |
| // Read verified email from proxy (if session was valid) | |
| const userEmail = req.headers['x-user-email'] || ''; | |
| req.userEmail = userEmail; | |
| // ββ Canonical user resolution ββ | |
| // If we have an email, check if this email already has a canonical user_id | |
| let canonicalUserId = incomingUserId; | |
| if (userEmail) { | |
| try { | |
| // Look up the FIRST user_id ever registered for this email | |
| const existing = db.prepare( | |
| `SELECT user_id FROM users WHERE email = ? ORDER BY first_seen_at ASC LIMIT 1` | |
| ).get(userEmail); | |
| if (existing && existing.user_id !== incomingUserId) { | |
| // This email already has a canonical user_id from a different environment. | |
| // Use the canonical one and migrate any orphaned data. | |
| canonicalUserId = existing.user_id; | |
| console.log( | |
| `π [identity] Email ${userEmail} β canonical user ${canonicalUserId.substring(0, 8)}β¦ ` + | |
| `(incoming ${incomingUserId.substring(0, 8)}β¦ will be merged)` | |
| ); | |
| // Auto-migrate documents from the incoming (non-canonical) user_id | |
| const docMigration = db.prepare( | |
| `UPDATE documents SET user_id = ? WHERE user_id = ?` | |
| ).run(canonicalUserId, incomingUserId); | |
| if (docMigration.changes > 0) { | |
| console.log(` π Migrated ${docMigration.changes} document(s) β ${canonicalUserId.substring(0, 8)}β¦`); | |
| } | |
| // Auto-migrate chats from the incoming user_id | |
| const chatMigration = db.prepare( | |
| `UPDATE chats SET user_id = ? WHERE user_id = ?` | |
| ).run(canonicalUserId, incomingUserId); | |
| if (chatMigration.changes > 0) { | |
| console.log(` π¬ Migrated ${chatMigration.changes} chat(s) β ${canonicalUserId.substring(0, 8)}β¦`); | |
| } | |
| // Clean up the non-canonical user record if it exists | |
| db.prepare(`DELETE FROM users WHERE user_id = ? AND email = ?`) | |
| .run(incomingUserId, userEmail); | |
| } else if (!existing) { | |
| // First time this email is seen β register the canonical mapping | |
| db.prepare(` | |
| INSERT INTO users (user_id, email, first_seen_at, last_seen_at) | |
| VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) | |
| `).run(incomingUserId, userEmail); | |
| } else { | |
| // Same user_id as canonical β just update last_seen | |
| db.prepare(` | |
| UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE user_id = ? | |
| `).run(canonicalUserId); | |
| } | |
| } catch (err) { | |
| // Non-fatal: don't block the request if identity resolution fails | |
| console.warn(`β οΈ Identity resolution failed:`, err.message); | |
| } | |
| } | |
| req.userId = canonicalUserId; | |
| console.log(`β User authenticated: ${canonicalUserId.substring(0, 10)}...${userEmail ? ` (${userEmail})` : ' (guest)'}`); | |
| next(); | |
| }; | |
| app.get('/health', (req, res) => { | |
| console.log('β€οΈ Health check requested'); | |
| try { | |
| // Test database connection | |
| db.prepare('SELECT 1 as test').get(); | |
| res.json({ | |
| status: 'active', | |
| database: 'connected', | |
| timestamp: new Date().toISOString(), | |
| uptime: process.uptime() | |
| }); | |
| } catch (error) { | |
| console.error('β Health check failed:', error); | |
| res.status(500).json({ | |
| status: 'degraded', | |
| database: 'disconnected', | |
| error: error.message | |
| }); | |
| } | |
| }); | |
| // Debug stats endpoint | |
| app.get('/debug/stats', (req, res) => { | |
| try { | |
| console.log('π Debug stats requested'); | |
| // Get user count | |
| const userCountStmt = db.prepare(` | |
| SELECT COUNT(DISTINCT user_id) as user_count FROM chats | |
| `); | |
| const userCount = userCountStmt.get().user_count; | |
| // Get chat count by user | |
| const chatsByUserStmt = db.prepare(` | |
| SELECT user_id, COUNT(*) as chat_count | |
| FROM chats | |
| GROUP BY user_id | |
| ORDER BY chat_count DESC | |
| LIMIT 10 | |
| `); | |
| const topUsers = chatsByUserStmt.all(); | |
| // Get message count by chat | |
| const messagesByChatStmt = db.prepare(` | |
| SELECT chat_id, COUNT(*) as message_count | |
| FROM messages | |
| GROUP BY chat_id | |
| ORDER BY message_count DESC | |
| LIMIT 10 | |
| `); | |
| const topChats = messagesByChatStmt.all(); | |
| // Get recent activity | |
| const recentChatsStmt = db.prepare(` | |
| SELECT id, user_id, title, updated_at | |
| FROM chats | |
| ORDER BY updated_at DESC | |
| LIMIT 5 | |
| `); | |
| const recentChats = recentChatsStmt.all(); | |
| res.json({ | |
| database: dbPath, | |
| users: { | |
| total: userCount, | |
| top: topUsers | |
| }, | |
| chats: { | |
| total: db.prepare('SELECT COUNT(*) as count FROM chats').get().count, | |
| recent: recentChats | |
| }, | |
| messages: { | |
| total: db.prepare('SELECT COUNT(*) as count FROM messages').get().count, | |
| top_chats: topChats | |
| }, | |
| chat_workspaces: { | |
| total: db.prepare('SELECT COUNT(*) as count FROM chat_workspaces').get().count | |
| }, | |
| documents: { | |
| total: db.prepare('SELECT COUNT(*) as count FROM documents').get().count | |
| }, | |
| timestamp: new Date().toISOString() | |
| }); | |
| } catch (error) { | |
| console.error('β Debug stats error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| app.get('/api/chats', requireUser, (req, res) => { | |
| try { | |
| console.log(`π Fetching chats for user: ${req.userId.substring(0, 10)}...`); | |
| const limit = Math.min(parseInt(req.query.limit) || 50, 100); | |
| const offset = parseInt(req.query.offset) || 0; | |
| const stmt = db.prepare(` | |
| SELECT id, title, model, created_at, updated_at | |
| FROM chats | |
| WHERE user_id = ? | |
| ORDER BY updated_at DESC | |
| LIMIT ? OFFSET ? | |
| `); | |
| const chats = stmt.all(req.userId, limit, offset); | |
| console.log(`β Found ${chats.length} chats for user`); | |
| res.json(chats); | |
| } catch (err) { | |
| console.error(`β Error fetching chats for user ${req.userId}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.post('/api/chats', requireUser, (req, res) => { | |
| try { | |
| const { title, model } = req.body; | |
| console.log(`π Creating new chat for user ${req.userId.substring(0, 10)}...`); | |
| console.log(` Title: ${title}, Model: ${model || 'default'}`); | |
| if (!title || typeof title !== 'string') { | |
| console.log(`β Invalid title: ${title}`); | |
| return res.status(400).json({ error: 'Valid title required' }); | |
| } | |
| const stmt = db.prepare(` | |
| INSERT INTO chats (user_id, title, model) | |
| VALUES (?, ?, ?) | |
| `); | |
| const info = stmt.run(req.userId, title.trim(), model || 'default'); | |
| console.log(`β Chat created with ID: ${info.lastInsertRowid}`); | |
| const newChat = db.prepare(` | |
| SELECT * FROM chats WHERE id = ? | |
| `).get(info.lastInsertRowid); | |
| console.log(`π New chat details:`, newChat); | |
| res.status(201).json(newChat); | |
| } catch (err) { | |
| console.error(`β Error creating chat for user ${req.userId}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.get('/api/chats/:id/messages', requireUser, (req, res) => { | |
| try { | |
| const chatId = req.params.id; | |
| console.log(`π© Fetching messages for chat ${chatId} (user: ${req.userId.substring(0, 10)}...)`); | |
| // Verify ownership | |
| const chat = db.prepare(` | |
| SELECT id, title, user_id FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| console.log(`β Chat ${chatId} not found or not owned by user ${req.userId}`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(`β Chat found: "${chat.title}" (ID: ${chat.id})`); | |
| const messages = db.prepare(` | |
| SELECT * FROM messages | |
| WHERE chat_id = ? | |
| ORDER BY timestamp ASC | |
| `).all(chatId); | |
| console.log(`β Found ${messages.length} messages for chat ${chatId}`); | |
| // Log first few messages | |
| if (messages.length > 0) { | |
| console.log(` Sample messages:`); | |
| messages.slice(0, 3).forEach(msg => { | |
| console.log(` - ${msg.role}: ${msg.content.substring(0, 50)}...`); | |
| }); | |
| if (messages.length > 3) { | |
| console.log(` ... and ${messages.length - 3} more`); | |
| } | |
| } | |
| res.json(messages); | |
| } catch (err) { | |
| console.error(`β Error fetching messages for chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.post('/api/messages', requireUser, (req, res) => { | |
| try { | |
| const { chatId, role, content, model } = req.body; | |
| console.log(`πΎ Saving message to chat ${chatId}`); | |
| console.log(` Role: ${role}, Model: ${model || 'none'}`); | |
| console.log(` Content preview: ${content.substring(0, 100)}...`); | |
| if (!chatId || !role || !content) { | |
| console.log(`β Invalid message data: chatId=${chatId}, role=${role}, content=${content ? 'present' : 'missing'}`); | |
| return res.status(400).json({ error: 'Invalid message data' }); | |
| } | |
| // Verify ownership | |
| const chat = db.prepare(` | |
| SELECT id, title, user_id FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| console.log(`β Chat ${chatId} not found or not owned by user ${req.userId}`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(`β Chat verified: "${chat.title}"`); | |
| // Insert message | |
| const insertStmt = db.prepare(` | |
| INSERT INTO messages (chat_id, role, content, model) | |
| VALUES (?, ?, ?, ?) | |
| `); | |
| const info = insertStmt.run(chatId, role, content, model || null); | |
| console.log(`β Message saved with ID: ${info.lastInsertRowid}`); | |
| // Update chat timestamp | |
| db.prepare(` | |
| UPDATE chats | |
| SET updated_at = CURRENT_TIMESTAMP | |
| WHERE id = ? | |
| `).run(chatId); | |
| console.log(`π Updated chat ${chatId} timestamp`); | |
| // Return the saved message | |
| const newMessage = db.prepare(` | |
| SELECT * FROM messages | |
| WHERE id = ? | |
| `).get(info.lastInsertRowid); | |
| console.log(`π€ Returning saved message with ID: ${newMessage.id}`); | |
| res.status(201).json(newMessage); | |
| } catch (err) { | |
| console.error(`β Error saving message:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.get('/api/chats/:id', requireUser, (req, res) => { | |
| try { | |
| const chatId = req.params.id; | |
| console.log(`π Fetching chat ${chatId} details for user ${req.userId.substring(0, 10)}...`); | |
| const chat = db.prepare(` | |
| SELECT * FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| console.log(`β Chat ${chatId} not found`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(`β Chat found:`, chat); | |
| res.json(chat); | |
| } catch (err) { | |
| console.error(`β Error fetching chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.delete('/api/chats/:id', requireUser, (req, res) => { | |
| try { | |
| const chatId = req.params.id; | |
| console.log(`ποΈ Deleting chat ${chatId} for user ${req.userId.substring(0, 10)}...`); | |
| // First, get chat info for logging | |
| const chat = db.prepare(` | |
| SELECT title FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| console.log(`β Chat ${chatId} not found or not owned by user`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(` Deleting chat: "${chat.title}"`); | |
| // Count messages before deletion | |
| const messageCount = db.prepare(` | |
| SELECT COUNT(*) as count FROM messages WHERE chat_id = ? | |
| `).get(chatId).count; | |
| console.log(` This will delete ${messageCount} messages`); | |
| const stmt = db.prepare(` | |
| DELETE FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `); | |
| const info = stmt.run(req.params.id, req.userId); | |
| if (info.changes === 0) { | |
| console.log(`β No rows deleted`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(`β Deleted chat ${chatId} and ${messageCount} messages`); | |
| res.json({ | |
| success: true, | |
| chatId: chatId, | |
| messagesDeleted: messageCount | |
| }); | |
| } catch (err) { | |
| console.error(`β Error deleting chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.patch('/api/chats/:id', requireUser, (req, res) => { | |
| try { | |
| const { title } = req.body; | |
| const chatId = req.params.id; | |
| console.log(`βοΈ Renaming chat ${chatId} for user ${req.userId.substring(0, 10)}...`); | |
| console.log(` New title: ${title}`); | |
| if (!title || typeof title !== 'string') { | |
| console.log(`β Invalid title: ${title}`); | |
| return res.status(400).json({ error: 'Valid title required' }); | |
| } | |
| // Get old title for logging | |
| const oldChat = db.prepare(` | |
| SELECT title FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!oldChat) { | |
| console.log(`β Chat ${chatId} not found`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(` Old title: "${oldChat.title}"`); | |
| const stmt = db.prepare(` | |
| UPDATE chats | |
| SET title = ?, updated_at = CURRENT_TIMESTAMP | |
| WHERE id = ? AND user_id = ? | |
| `); | |
| const info = stmt.run(title.trim(), req.params.id, req.userId); | |
| if (info.changes === 0) { | |
| console.log(`β No rows updated`); | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| console.log(`β Chat renamed from "${oldChat.title}" to "${title}"`); | |
| res.json({ | |
| success: true, | |
| oldTitle: oldChat.title, | |
| newTitle: title | |
| }); | |
| } catch (err) { | |
| console.error(`β Error renaming chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.get('/api/chats/:id/workspace', requireUser, (req, res) => { | |
| try { | |
| const chatId = req.params.id; | |
| const chat = db.prepare(` | |
| SELECT id, title | |
| FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| const workspace = db.prepare(` | |
| SELECT chat_id, content, updated_at | |
| FROM chat_workspaces | |
| WHERE chat_id = ? | |
| `).get(chatId); | |
| res.json({ | |
| chat_id: chatId, | |
| content: workspace?.content ?? '', | |
| updated_at: workspace?.updated_at ?? null | |
| }); | |
| } catch (err) { | |
| console.error(`β Error fetching workspace for chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.put('/api/chats/:id/workspace', requireUser, (req, res) => { | |
| try { | |
| const chatId = req.params.id; | |
| const { content } = req.body; | |
| if (typeof content !== 'string') { | |
| return res.status(400).json({ error: 'Workspace content must be a string' }); | |
| } | |
| if (content.length > 5_000_000) { | |
| return res.status(413).json({ error: 'Workspace content too large' }); | |
| } | |
| const chat = db.prepare(` | |
| SELECT id, title | |
| FROM chats | |
| WHERE id = ? AND user_id = ? | |
| `).get(chatId, req.userId); | |
| if (!chat) { | |
| return res.status(404).json({ error: 'Chat not found' }); | |
| } | |
| db.prepare(` | |
| INSERT INTO chat_workspaces (chat_id, content, updated_at) | |
| VALUES (?, ?, CURRENT_TIMESTAMP) | |
| ON CONFLICT(chat_id) DO UPDATE SET | |
| content = excluded.content, | |
| updated_at = CURRENT_TIMESTAMP | |
| `).run(chatId, content); | |
| const updated = db.prepare(` | |
| SELECT chat_id, content, updated_at | |
| FROM chat_workspaces | |
| WHERE chat_id = ? | |
| `).get(chatId); | |
| res.json(updated); | |
| } catch (err) { | |
| console.error(`β Error saving workspace for chat ${req.params.id}:`, err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // ============================================ | |
| // ANT-EDITOR DOCUMENT ENDPOINTS | |
| // These are completely separate from the chat system. | |
| // ============================================ | |
| // List all documents for a user | |
| app.get('/api/documents', requireUser, (req, res) => { | |
| try { | |
| const limit = Math.min(parseInt(req.query.limit) || 50, 200); | |
| const offset = Math.max(parseInt(req.query.offset) || 0, 0); | |
| const stmt = db.prepare(` | |
| SELECT id, title, language, created_at, updated_at, | |
| LENGTH(content) as content_length | |
| FROM documents | |
| WHERE user_id = ? | |
| ORDER BY updated_at DESC | |
| LIMIT ? OFFSET ? | |
| `); | |
| const documents = stmt.all(req.userId, limit, offset); | |
| const countStmt = db.prepare('SELECT COUNT(*) as total FROM documents WHERE user_id = ?'); | |
| const { total } = countStmt.get(req.userId); | |
| res.json({ documents, total, limit, offset }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // List all active shared links for the current user (with document titles) | |
| // IMPORTANT: Must be registered BEFORE /api/documents/:id to prevent | |
| // Express from matching 'shared-links' as the :id parameter. | |
| app.get('/api/documents/shared-links', requireUser, (req, res) => { | |
| try { | |
| const stmt = db.prepare(` | |
| SELECT | |
| sd.share_token, | |
| sd.document_id, | |
| sd.created_at, | |
| d.title AS document_title | |
| FROM shared_documents sd | |
| JOIN documents d ON d.id = sd.document_id | |
| WHERE sd.created_by = ? | |
| ORDER BY sd.created_at DESC | |
| `); | |
| const links = stmt.all(req.userId); | |
| console.log(`π Listed ${links.length} shared link(s) for user ${req.userId.substring(0, 8)}β¦`); | |
| res.json({ shared_links: links }); | |
| } catch (err) { | |
| console.error('Failed to list shared links:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Get a single document (with full content) | |
| app.get('/api/documents/:id', requireUser, (req, res) => { | |
| try { | |
| const doc = db.prepare(` | |
| SELECT * FROM documents | |
| WHERE id = ? AND user_id = ? | |
| `).get(req.params.id, req.userId); | |
| if (!doc) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| res.json(doc); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Create a new document | |
| app.post('/api/documents', requireUser, (req, res) => { | |
| try { | |
| const { title, content, language } = req.body; | |
| const docTitle = (title && typeof title === 'string') ? title.trim() : 'Untitled Document'; | |
| const docContent = (content && typeof content === 'string') ? content : ''; | |
| const docLanguage = (language && typeof language === 'string') ? language.trim().substring(0, 2) : 'en'; | |
| const stmt = db.prepare(` | |
| INSERT INTO documents (user_id, title, content, language) | |
| VALUES (?, ?, ?, ?) | |
| `); | |
| const info = stmt.run(req.userId, docTitle, docContent, docLanguage); | |
| const newDoc = db.prepare('SELECT * FROM documents WHERE id = ?').get(info.lastInsertRowid); | |
| res.status(201).json(newDoc); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Update a document (full save β title + content) | |
| app.put('/api/documents/:id', requireUser, (req, res) => { | |
| try { | |
| const { title, content, language } = req.body; | |
| // Verify ownership | |
| const existing = db.prepare(` | |
| SELECT id FROM documents WHERE id = ? AND user_id = ? | |
| `).get(req.params.id, req.userId); | |
| if (!existing) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| const updates = []; | |
| const params = []; | |
| if (title !== undefined) { | |
| updates.push('title = ?'); | |
| params.push(typeof title === 'string' ? title.trim() : 'Untitled Document'); | |
| } | |
| if (content !== undefined) { | |
| updates.push('content = ?'); | |
| params.push(typeof content === 'string' ? content : ''); | |
| } | |
| if (language !== undefined) { | |
| updates.push('language = ?'); | |
| params.push(typeof language === 'string' ? language.trim().substring(0, 2) : 'en'); | |
| } | |
| if (updates.length === 0) { | |
| return res.status(400).json({ error: 'No fields to update' }); | |
| } | |
| updates.push('updated_at = CURRENT_TIMESTAMP'); | |
| params.push(req.params.id, req.userId); | |
| db.prepare(` | |
| UPDATE documents SET ${updates.join(', ')} | |
| WHERE id = ? AND user_id = ? | |
| `).run(...params); | |
| const updatedDoc = db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id); | |
| res.json(updatedDoc); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Rename a document (title only) | |
| app.patch('/api/documents/:id', requireUser, (req, res) => { | |
| try { | |
| const { title } = req.body; | |
| if (!title || typeof title !== 'string') { | |
| return res.status(400).json({ error: 'Valid title required' }); | |
| } | |
| const stmt = db.prepare(` | |
| UPDATE documents | |
| SET title = ?, updated_at = CURRENT_TIMESTAMP | |
| WHERE id = ? AND user_id = ? | |
| `); | |
| const info = stmt.run(title.trim(), req.params.id, req.userId); | |
| if (info.changes === 0) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| res.json({ success: true }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Delete a document | |
| app.delete('/api/documents/:id', requireUser, (req, res) => { | |
| try { | |
| const stmt = db.prepare(` | |
| DELETE FROM documents | |
| WHERE id = ? AND user_id = ? | |
| `); | |
| const info = stmt.run(req.params.id, req.userId); | |
| if (info.changes === 0) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| res.json({ success: true }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // ============================================ | |
| // DOCUMENT SHARING ENDPOINTS | |
| // Share a document via a randomly generated token. | |
| // Create/delete share requires auth. Access via token is public. | |
| // ============================================ | |
| const crypto = require('crypto'); | |
| // Create (or return existing) share link for a document | |
| app.post('/api/documents/:id/share', requireUser, (req, res) => { | |
| try { | |
| const docId = req.params.id; | |
| // Verify ownership | |
| const doc = db.prepare( | |
| 'SELECT id, title FROM documents WHERE id = ? AND user_id = ?' | |
| ).get(docId, req.userId); | |
| if (!doc) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| // Check if a share link already exists for this doc | |
| const existing = db.prepare( | |
| 'SELECT share_token FROM shared_documents WHERE document_id = ? AND created_by = ?' | |
| ).get(docId, req.userId); | |
| if (existing) { | |
| return res.json({ share_token: existing.share_token }); | |
| } | |
| // Generate a new share token (URL-safe UUID) | |
| const shareToken = crypto.randomUUID(); | |
| db.prepare( | |
| 'INSERT INTO shared_documents (share_token, document_id, created_by) VALUES (?, ?, ?)' | |
| ).run(shareToken, docId, req.userId); | |
| console.log(`π Share link created for doc ${docId} β token ${shareToken.substring(0, 8)}β¦`); | |
| res.status(201).json({ share_token: shareToken }); | |
| } catch (err) { | |
| console.error('Failed to create share link:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Revoke a share link | |
| app.delete('/api/documents/:id/share', requireUser, (req, res) => { | |
| try { | |
| const docId = req.params.id; | |
| // Verify ownership | |
| const doc = db.prepare( | |
| 'SELECT id FROM documents WHERE id = ? AND user_id = ?' | |
| ).get(docId, req.userId); | |
| if (!doc) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| const info = db.prepare( | |
| 'DELETE FROM shared_documents WHERE document_id = ? AND created_by = ?' | |
| ).run(docId, req.userId); | |
| if (info.changes === 0) { | |
| return res.status(404).json({ error: 'No share link found for this document' }); | |
| } | |
| console.log(`πβ Share link revoked for doc ${docId}`); | |
| res.json({ success: true }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // ββ Public shared document access (NO auth) ββ | |
| // Get a shared document by token | |
| app.get('/api/shared/:token', (req, res) => { | |
| try { | |
| const token = req.params.token; | |
| // Validate token format (UUID) | |
| if (!token || typeof token !== 'string' || token.length < 10) { | |
| return res.status(400).json({ error: 'Invalid share token' }); | |
| } | |
| const share = db.prepare( | |
| 'SELECT document_id FROM shared_documents WHERE share_token = ?' | |
| ).get(token); | |
| if (!share) { | |
| return res.status(404).json({ error: 'Shared document not found or link has been revoked' }); | |
| } | |
| const doc = db.prepare( | |
| 'SELECT id, title, content, language, created_at, updated_at FROM documents WHERE id = ?' | |
| ).get(share.document_id); | |
| if (!doc) { | |
| // Document was deleted β clean up orphaned share record | |
| db.prepare('DELETE FROM shared_documents WHERE share_token = ?').run(token); | |
| return res.status(404).json({ error: 'The shared document no longer exists' }); | |
| } | |
| // Return document WITHOUT user_id (privacy) | |
| res.json({ | |
| id: doc.id, | |
| title: doc.title, | |
| content: doc.content, | |
| language: doc.language, | |
| content_length: (doc.content || '').length, | |
| created_at: doc.created_at, | |
| updated_at: doc.updated_at, | |
| }); | |
| } catch (err) { | |
| console.error('Failed to fetch shared document:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Save edits to a shared document by token (public β no auth) | |
| app.put('/api/shared/:token', (req, res) => { | |
| try { | |
| const token = req.params.token; | |
| const { title, content } = req.body; | |
| // Validate token format | |
| if (!token || typeof token !== 'string' || token.length < 10) { | |
| return res.status(400).json({ error: 'Invalid share token' }); | |
| } | |
| const share = db.prepare( | |
| 'SELECT document_id FROM shared_documents WHERE share_token = ?' | |
| ).get(token); | |
| if (!share) { | |
| return res.status(404).json({ error: 'Shared document not found or link has been revoked' }); | |
| } | |
| const updates = []; | |
| const params = []; | |
| if (title !== undefined) { | |
| updates.push('title = ?'); | |
| params.push(typeof title === 'string' ? title.trim() : 'Untitled Document'); | |
| } | |
| if (content !== undefined) { | |
| updates.push('content = ?'); | |
| params.push(typeof content === 'string' ? content : ''); | |
| } | |
| if (updates.length === 0) { | |
| return res.status(400).json({ error: 'No fields to update' }); | |
| } | |
| updates.push('updated_at = CURRENT_TIMESTAMP'); | |
| params.push(share.document_id); | |
| db.prepare( | |
| `UPDATE documents SET ${updates.join(', ')} WHERE id = ?` | |
| ).run(...params); | |
| const updatedDoc = db.prepare( | |
| 'SELECT id, title, content, language, created_at, updated_at FROM documents WHERE id = ?' | |
| ).get(share.document_id); | |
| res.json({ | |
| id: updatedDoc.id, | |
| title: updatedDoc.title, | |
| content: updatedDoc.content, | |
| language: updatedDoc.language, | |
| content_length: (updatedDoc.content || '').length, | |
| created_at: updatedDoc.created_at, | |
| updated_at: updatedDoc.updated_at, | |
| }); | |
| } catch (err) { | |
| console.error('Failed to save shared document:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Beacon-save for shared documents (for page close via navigator.sendBeacon) | |
| app.post('/api/shared/:token/beacon-save', (req, res) => { | |
| try { | |
| const token = req.params.token; | |
| const { title, content } = req.body; | |
| if (!token || typeof token !== 'string' || token.length < 10) { | |
| return res.status(400).json({ error: 'Invalid share token' }); | |
| } | |
| const share = db.prepare( | |
| 'SELECT document_id FROM shared_documents WHERE share_token = ?' | |
| ).get(token); | |
| if (!share) { | |
| return res.status(404).json({ error: 'Shared document not found' }); | |
| } | |
| const updates = []; | |
| const params = []; | |
| if (title !== undefined) { | |
| updates.push('title = ?'); | |
| params.push(typeof title === 'string' ? title.trim() : 'Untitled Document'); | |
| } | |
| if (content !== undefined) { | |
| updates.push('content = ?'); | |
| params.push(typeof content === 'string' ? content : ''); | |
| } | |
| if (updates.length > 0) { | |
| updates.push('updated_at = CURRENT_TIMESTAMP'); | |
| params.push(share.document_id); | |
| db.prepare( | |
| `UPDATE documents SET ${updates.join(', ')} WHERE id = ?` | |
| ).run(...params); | |
| } | |
| res.json({ success: true }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Beacon-save endpoint (POST because sendBeacon only supports POST) | |
| // Used for saving on page close via navigator.sendBeacon() | |
| app.post('/api/documents/:id/beacon-save', requireUser, (req, res) => { | |
| try { | |
| const { title, content } = req.body; | |
| const existing = db.prepare(` | |
| SELECT id FROM documents WHERE id = ? AND user_id = ? | |
| `).get(req.params.id, req.userId); | |
| if (!existing) { | |
| return res.status(404).json({ error: 'Document not found' }); | |
| } | |
| const updates = []; | |
| const params = []; | |
| if (title !== undefined) { | |
| updates.push('title = ?'); | |
| params.push(typeof title === 'string' ? title.trim() : 'Untitled Document'); | |
| } | |
| if (content !== undefined) { | |
| updates.push('content = ?'); | |
| params.push(typeof content === 'string' ? content : ''); | |
| } | |
| if (updates.length > 0) { | |
| updates.push('updated_at = CURRENT_TIMESTAMP'); | |
| params.push(req.params.id, req.userId); | |
| db.prepare(` | |
| UPDATE documents SET ${updates.join(', ')} | |
| WHERE id = ? AND user_id = ? | |
| `).run(...params); | |
| } | |
| res.json({ success: true }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // ============================================ | |
| // ERROR HANDLING MIDDLEWARE | |
| // ============================================ | |
| app.use((req, res, next) => { | |
| console.log(`β Route not found: ${req.method} ${req.url}`); | |
| res.status(404).json({ | |
| error: 'Route not found', | |
| method: req.method, | |
| path: req.url | |
| }); | |
| }); | |
| app.use((err, req, res, next) => { | |
| console.error(`π₯ Unhandled error:`, err); | |
| console.error(` Request: ${req.method} ${req.url}`); | |
| console.error(` User ID: ${req.userId || 'unknown'}`); | |
| res.status(500).json({ | |
| error: 'Internal server error', | |
| message: err.message, | |
| timestamp: new Date().toISOString() | |
| }); | |
| }); | |
| // ============================================ | |
| // START SERVER | |
| // ============================================ | |
| const port = process.env.PORT || 7860; | |
| app.listen(port, () => { | |
| console.log(`\nπ ===========================================`); | |
| console.log(`π NeuralStream Chat Storage v2.0.0`); | |
| console.log(`π Listening on port ${port}`); | |
| console.log(`π Database: ${dbPath}`); | |
| console.log(`π Logging: COMPREHENSIVE`); | |
| console.log(`π ===========================================\n`); | |
| // Log startup timestamp and environment | |
| console.log(`π Server started at: ${new Date().toISOString()}`); | |
| console.log(`π Environment: ${process.env.NODE_ENV || 'development'}`); | |
| console.log(`π» Platform: ${process.platform}`); | |
| console.log(`π€ User: ${require('os').userInfo().username}`); | |
| // Test database with sample query | |
| try { | |
| const testResult = db.prepare('SELECT sqlite_version() as version').get(); | |
| console.log(`πΎ SQLite version: ${testResult.version}`); | |
| } catch (error) { | |
| console.error(`β Database test failed:`, error); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error(`β Failed to initialize database:`, error); | |
| process.exit(1); | |
| } | |