Add database migration scripts and configuration files
- Add verify-migration.js script for testing database migrations - Add database config module for centralized configuration - Add chutes.txt prompt for system responses - Update database implementation and testing documentation - Add database migration and setup scripts - Update session system and LLM tool configuration - Update deployment checklist and environment example - Update Dockerfile and docker-compose configuration
This commit is contained in:
@@ -5,7 +5,7 @@ This implementation adds database encryption at rest and secure session manageme
|
||||
## Features
|
||||
|
||||
### Phase 1.2: Database with Encryption at Rest
|
||||
- ✅ SQLite database with better-sqlite3
|
||||
- ✅ SQLCipher-encrypted SQLite database with better-sqlite3
|
||||
- ✅ Field-level AES-256-GCM encryption for sensitive data
|
||||
- ✅ PBKDF2 key derivation (100,000 iterations)
|
||||
- ✅ WAL mode for better concurrency
|
||||
@@ -24,16 +24,17 @@ This implementation adds database encryption at rest and secure session manageme
|
||||
## Architecture
|
||||
|
||||
### Database Schema
|
||||
- **users**: User accounts with encrypted email, name, 2FA secrets
|
||||
- **users**: User accounts with encrypted email, name, 2FA secrets + JSON data column
|
||||
- **sessions**: Active sessions for revocation
|
||||
- **refresh_tokens**: Refresh tokens with device fingerprinting
|
||||
- **token_blacklist**: Immediate token revocation
|
||||
- **affiliates**, **withdrawals**, **feature_requests**, **contact_messages**
|
||||
- **affiliate_accounts** (current app), **affiliates** (legacy), **withdrawals**, **feature_requests**, **contact_messages**
|
||||
- **audit_log**: Comprehensive security event logging
|
||||
- **payment_sessions**: DoDo payment tracking
|
||||
|
||||
### Encryption
|
||||
- **Algorithm**: AES-256-GCM with authenticated encryption
|
||||
- **Database**: SQLCipher encryption at rest
|
||||
- **Algorithm**: AES-256-GCM with authenticated encryption (field-level)
|
||||
- **Key Derivation**: PBKDF2 with 100,000 iterations
|
||||
- **Per-field**: Sensitive fields encrypted individually
|
||||
- **Token Storage**: PBKDF2 hashed (not encrypted) for secure comparison
|
||||
@@ -65,8 +66,12 @@ Optional:
|
||||
```bash
|
||||
USE_JSON_DATABASE=1 # Use JSON files instead of database (for rollback)
|
||||
DATABASE_PATH=./.data/shopify_ai.db
|
||||
DATABASE_KEY_FILE=./.data/.encryption_key
|
||||
DATABASE_BACKUP_ENABLED=1
|
||||
DATABASE_WAL_MODE=1
|
||||
DATABASE_USE_SQLCIPHER=1
|
||||
DATABASE_CIPHER_COMPAT=4
|
||||
DATABASE_KDF_ITER=64000
|
||||
JWT_ACCESS_TOKEN_TTL=900 # 15 minutes in seconds
|
||||
JWT_REFRESH_TOKEN_TTL=604800 # 7 days in seconds
|
||||
```
|
||||
@@ -135,14 +140,14 @@ export USE_JSON_DATABASE=1
|
||||
|
||||
### Verify Database Setup
|
||||
```bash
|
||||
# Check database exists and tables are created
|
||||
sqlite3 ./.data/shopify_ai.db ".tables"
|
||||
# Check database exists and tables are created (SQLCipher)
|
||||
sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
|
||||
|
||||
# Should output:
|
||||
# affiliates payment_sessions token_blacklist
|
||||
# audit_log refresh_tokens users
|
||||
# contact_messages sessions withdrawals
|
||||
# feature_requests
|
||||
# affiliate_accounts payment_sessions token_blacklist
|
||||
# affiliates refresh_tokens users
|
||||
# audit_log sessions withdrawals
|
||||
# contact_messages feature_requests
|
||||
```
|
||||
|
||||
### Test Encryption
|
||||
@@ -161,8 +166,8 @@ node scripts/migrate-to-database.js
|
||||
|
||||
### Database Health
|
||||
- Check file size: `ls -lh ./.data/shopify_ai.db`
|
||||
- Check WAL mode: `sqlite3 ./.data/shopify_ai.db "PRAGMA journal_mode;"`
|
||||
- Check tables: `sqlite3 ./.data/shopify_ai.db ".tables"`
|
||||
- Check WAL mode: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; PRAGMA journal_mode;"`
|
||||
- Check tables: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"`
|
||||
|
||||
### Audit Logs
|
||||
Audit logs are stored in the `audit_log` table and include:
|
||||
|
||||
@@ -235,15 +235,15 @@ docker exec shopify-ai-test node /opt/webchat/scripts/migrate-to-database.js
|
||||
### 7. Verify Tables
|
||||
|
||||
```bash
|
||||
docker exec shopify-ai-test sqlite3 /home/web/data/.data/shopify_ai.db ".tables"
|
||||
docker exec shopify-ai-test sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
affiliates payment_sessions token_blacklist
|
||||
audit_log refresh_tokens users
|
||||
contact_messages sessions withdrawals
|
||||
feature_requests
|
||||
affiliate_accounts payment_sessions token_blacklist
|
||||
affiliates refresh_tokens users
|
||||
audit_log sessions withdrawals
|
||||
contact_messages feature_requests
|
||||
```
|
||||
|
||||
### 8. Check Encryption Keys Persisted
|
||||
|
||||
10
chat/package-lock.json
generated
10
chat/package-lock.json
generated
@@ -15,7 +15,6 @@
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^7.0.7",
|
||||
"pdfkit": "^0.17.2",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
@@ -1263,15 +1262,6 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { DATA_ROOT, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || '/home/web/data/.data';
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const USE_JSON_DATABASE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
|
||||
|
||||
async function initializeDatabase() {
|
||||
@@ -33,7 +33,15 @@ async function initializeDatabase() {
|
||||
|
||||
if (dbExists) {
|
||||
console.log('✅ Database already exists:', DATABASE_PATH);
|
||||
|
||||
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (persistedKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
|
||||
// Verify encryption key is set
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set!');
|
||||
@@ -48,6 +56,14 @@ async function initializeDatabase() {
|
||||
console.log('🔧 Database not found, setting up new database...');
|
||||
|
||||
// Generate encryption key if not provided
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (persistedKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
const generatedKey = crypto.randomBytes(32).toString('hex');
|
||||
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
|
||||
@@ -57,7 +73,7 @@ async function initializeDatabase() {
|
||||
console.log('⚠️ Add this to your environment configuration to persist it!');
|
||||
|
||||
// Save to a file for persistence
|
||||
const keyFile = path.join(dataDir, '.encryption_key');
|
||||
const keyFile = KEY_FILE || path.join(dataDir, '.encryption_key');
|
||||
fs.writeFileSync(keyFile, generatedKey, { mode: 0o600 });
|
||||
console.log('⚠️ Saved to:', keyFile);
|
||||
}
|
||||
@@ -86,8 +102,8 @@ async function initializeDatabase() {
|
||||
}
|
||||
|
||||
// Check if there are JSON files to migrate
|
||||
const usersFile = path.join(DATA_ROOT, 'users.json');
|
||||
const sessionsFile = path.join(DATA_ROOT, 'user-sessions.json');
|
||||
const usersFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'users.json');
|
||||
const sessionsFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'user-sessions.json');
|
||||
|
||||
const hasJsonData = fs.existsSync(usersFile) || fs.existsSync(sessionsFile);
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const userRepo = require('../src/repositories/userRepository');
|
||||
const { initEncryption, encrypt } = require('../src/utils/encryption');
|
||||
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
|
||||
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
|
||||
const USERS_FILE = path.join(DATA_ROOT, 'users.json');
|
||||
const SESSIONS_FILE = path.join(DATA_ROOT, 'user-sessions.json');
|
||||
const AFFILIATES_FILE = path.join(DATA_ROOT, 'affiliates.json');
|
||||
const USERS_FILE = path.join(STATE_DIR, 'users.json');
|
||||
const SESSIONS_FILE = path.join(STATE_DIR, 'user-sessions.json');
|
||||
const AFFILIATES_FILE = path.join(STATE_DIR, 'affiliates.json');
|
||||
|
||||
async function loadJsonFile(filePath, defaultValue = []) {
|
||||
try {
|
||||
@@ -44,30 +44,88 @@ async function migrateUsers() {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, email_encrypted, name, name_encrypted, password_hash,
|
||||
providers, email_verified, verification_token, verification_expires_at,
|
||||
reset_token, reset_expires_at, plan, billing_status, billing_email,
|
||||
payment_method_last4, subscription_renews_at, referred_by_affiliate_code,
|
||||
affiliate_attribution_at, affiliate_payouts, two_factor_secret,
|
||||
two_factor_enabled, created_at, updated_at, last_login_at, data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
email_encrypted = excluded.email_encrypted,
|
||||
name = excluded.name,
|
||||
name_encrypted = excluded.name_encrypted,
|
||||
password_hash = excluded.password_hash,
|
||||
providers = excluded.providers,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at,
|
||||
reset_token = excluded.reset_token,
|
||||
reset_expires_at = excluded.reset_expires_at,
|
||||
plan = excluded.plan,
|
||||
billing_status = excluded.billing_status,
|
||||
billing_email = excluded.billing_email,
|
||||
payment_method_last4 = excluded.payment_method_last4,
|
||||
subscription_renews_at = excluded.subscription_renews_at,
|
||||
referred_by_affiliate_code = excluded.referred_by_affiliate_code,
|
||||
affiliate_attribution_at = excluded.affiliate_attribution_at,
|
||||
affiliate_payouts = excluded.affiliate_payouts,
|
||||
two_factor_secret = excluded.two_factor_secret,
|
||||
two_factor_enabled = excluded.two_factor_enabled,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
data = excluded.data
|
||||
`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existing = userRepo.getUserById(user.id);
|
||||
if (existing) {
|
||||
console.log(` Skipping existing user: ${user.email}`);
|
||||
success++;
|
||||
const normalizedEmail = (user.email || '').trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
console.log(' Skipping user without email');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
userRepo.createUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name || null,
|
||||
passwordHash: user.passwordHash || user.password_hash,
|
||||
providers: user.providers || [],
|
||||
emailVerified: user.emailVerified,
|
||||
verificationToken: user.verificationToken || null,
|
||||
verificationExpiresAt: user.verificationExpiresAt || null,
|
||||
plan: user.plan || 'hobby',
|
||||
billingStatus: user.billingStatus || 'active',
|
||||
billingEmail: user.billingEmail || user.email
|
||||
});
|
||||
const passwordHash = user.passwordHash || user.password_hash || user.password || '';
|
||||
const providers = Array.isArray(user.providers) ? user.providers : [];
|
||||
const affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||||
const dataPayload = JSON.stringify({ ...user, email: normalizedEmail || user.email || '' });
|
||||
const emailEncrypted = normalizedEmail ? encrypt(normalizedEmail) : '';
|
||||
const nameEncrypted = user.name ? encrypt(user.name) : null;
|
||||
const twoFactorEncrypted = user.twoFactorSecret ? encrypt(user.twoFactorSecret) : null;
|
||||
|
||||
stmt.run(
|
||||
user.id,
|
||||
normalizedEmail,
|
||||
emailEncrypted,
|
||||
user.name || null,
|
||||
nameEncrypted,
|
||||
passwordHash,
|
||||
JSON.stringify(providers),
|
||||
user.emailVerified ? 1 : 0,
|
||||
user.verificationToken || null,
|
||||
user.verificationExpiresAt || null,
|
||||
user.resetToken || null,
|
||||
user.resetExpiresAt || null,
|
||||
user.plan || 'hobby',
|
||||
user.billingStatus || 'active',
|
||||
user.billingEmail || user.email || '',
|
||||
user.paymentMethodLast4 || '',
|
||||
user.subscriptionRenewsAt || null,
|
||||
user.referredByAffiliateCode || null,
|
||||
user.affiliateAttributionAt || null,
|
||||
JSON.stringify(affiliatePayouts),
|
||||
twoFactorEncrypted,
|
||||
user.twoFactorEnabled ? 1 : 0,
|
||||
user.createdAt || Date.now(),
|
||||
user.updatedAt || user.createdAt || Date.now(),
|
||||
user.lastLoginAt || null,
|
||||
dataPayload
|
||||
);
|
||||
|
||||
console.log(` ✓ Migrated user: ${user.email}`);
|
||||
success++;
|
||||
@@ -99,8 +157,19 @@ async function migrateSessions() {
|
||||
let expired = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
const sessionRepo = require('../src/repositories/sessionRepository');
|
||||
|
||||
const userExistsStmt = db.prepare('SELECT id FROM users WHERE id = ?');
|
||||
const sessionStmt = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, user_id, token, refresh_token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at, last_accessed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
token = excluded.token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_accessed_at = excluded.last_accessed_at
|
||||
`);
|
||||
|
||||
for (const [token, session] of Object.entries(sessions)) {
|
||||
try {
|
||||
// Skip expired sessions
|
||||
@@ -110,25 +179,25 @@ async function migrateSessions() {
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = userRepo.getUserById(session.userId);
|
||||
const user = userExistsStmt.get(session.userId);
|
||||
if (!user) {
|
||||
console.log(` Skipping session for non-existent user: ${session.userId}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create session in database
|
||||
sessionRepo.createSession({
|
||||
id: session.id || require('crypto').randomUUID(),
|
||||
userId: session.userId,
|
||||
token: token,
|
||||
deviceFingerprint: session.deviceFingerprint || null,
|
||||
ipAddress: session.ipAddress || null,
|
||||
userAgent: session.userAgent || null,
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt || now,
|
||||
lastAccessedAt: session.lastAccessedAt || now
|
||||
});
|
||||
sessionStmt.run(
|
||||
token,
|
||||
session.userId,
|
||||
token,
|
||||
null,
|
||||
session.deviceFingerprint || null,
|
||||
session.ipAddress || null,
|
||||
session.userAgent || null,
|
||||
session.expiresAt,
|
||||
session.createdAt || now,
|
||||
session.lastAccessedAt || now
|
||||
);
|
||||
|
||||
success++;
|
||||
} catch (error) {
|
||||
@@ -156,38 +225,53 @@ async function migrateAffiliates() {
|
||||
let failed = 0;
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO affiliate_accounts (
|
||||
id, email, name, password_hash, codes, earnings, commission_rate,
|
||||
created_at, updated_at, last_login_at, last_payout_at,
|
||||
email_verified, verification_token, verification_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
name = excluded.name,
|
||||
password_hash = excluded.password_hash,
|
||||
codes = excluded.codes,
|
||||
earnings = excluded.earnings,
|
||||
commission_rate = excluded.commission_rate,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
last_payout_at = excluded.last_payout_at,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at
|
||||
`);
|
||||
|
||||
for (const affiliate of affiliates) {
|
||||
try {
|
||||
// Check if user exists
|
||||
const user = userRepo.getUserById(affiliate.userId);
|
||||
if (!user) {
|
||||
console.log(` Skipping affiliate for non-existent user: ${affiliate.userId}`);
|
||||
if (!affiliate.email) {
|
||||
console.log(' Skipping affiliate without email');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert affiliate
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO affiliates (
|
||||
id, user_id, codes, earnings, commission_rate,
|
||||
total_referrals, total_earnings_cents, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const affiliateEmail = (affiliate.email || '').trim().toLowerCase();
|
||||
stmt.run(
|
||||
affiliate.id || require('crypto').randomUUID(),
|
||||
affiliate.userId,
|
||||
affiliateEmail,
|
||||
affiliate.name || '',
|
||||
affiliate.password || affiliate.passwordHash || '',
|
||||
JSON.stringify(affiliate.codes || []),
|
||||
JSON.stringify(affiliate.earnings || []),
|
||||
affiliate.commissionRate || 0.15,
|
||||
affiliate.totalReferrals || 0,
|
||||
affiliate.totalEarningsCents || 0,
|
||||
affiliate.createdAt || Date.now(),
|
||||
affiliate.updatedAt || Date.now()
|
||||
affiliate.createdAt || new Date().toISOString(),
|
||||
affiliate.updatedAt || affiliate.createdAt || new Date().toISOString(),
|
||||
affiliate.lastLoginAt || null,
|
||||
affiliate.lastPayoutAt || null,
|
||||
affiliate.emailVerified ? 1 : 0,
|
||||
affiliate.verificationToken || null,
|
||||
affiliate.verificationExpiresAt || null
|
||||
);
|
||||
|
||||
console.log(` ✓ Migrated affiliate for user: ${user.email}`);
|
||||
console.log(` ✓ Migrated affiliate: ${affiliate.email}`);
|
||||
success++;
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed to migrate affiliate:`, error.message);
|
||||
@@ -229,7 +313,7 @@ async function createBackup() {
|
||||
|
||||
async function runMigration() {
|
||||
console.log('🔄 Starting database migration...');
|
||||
console.log(' Source: JSON files in', DATA_ROOT);
|
||||
console.log(' Source: JSON files in', STATE_DIR);
|
||||
console.log(' Target: Database at', DATABASE_PATH);
|
||||
|
||||
// Check if database exists
|
||||
@@ -239,13 +323,21 @@ async function runMigration() {
|
||||
}
|
||||
|
||||
// Initialize encryption
|
||||
if (!DATABASE_ENCRYPTION_KEY) {
|
||||
let encryptionKey = DATABASE_ENCRYPTION_KEY;
|
||||
if (!encryptionKey && KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (encryptionKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
initEncryption(DATABASE_ENCRYPTION_KEY);
|
||||
initEncryption(encryptionKey);
|
||||
console.log('✅ Encryption initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize encryption:', error.message);
|
||||
@@ -254,7 +346,12 @@ async function runMigration() {
|
||||
|
||||
// Initialize database
|
||||
try {
|
||||
initDatabase(DATABASE_PATH, { verbose: false });
|
||||
initDatabase(DATABASE_PATH, {
|
||||
verbose: false,
|
||||
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
console.log('✅ Database connected');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to database:', error.message);
|
||||
|
||||
@@ -8,12 +8,13 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const { DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
|
||||
const DATABASE_PATH = DB_PATH;
|
||||
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
|
||||
const WAL_MODE = process.env.DATABASE_WAL_MODE !== '0' && process.env.DATABASE_WAL_MODE !== 'false';
|
||||
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
|
||||
async function setupDatabase() {
|
||||
console.log('🔧 Setting up database...');
|
||||
@@ -26,14 +27,35 @@ async function setupDatabase() {
|
||||
console.log(' Created data directory:', dataDir);
|
||||
}
|
||||
|
||||
let encryptionKey = DATABASE_ENCRYPTION_KEY;
|
||||
|
||||
// Check if encryption key is provided
|
||||
if (!DATABASE_ENCRYPTION_KEY) {
|
||||
if (!encryptionKey) {
|
||||
if (fs.existsSync(KEY_FILE)) {
|
||||
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (encryptionKey) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
|
||||
console.log('✅ Loaded encryption key from file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!encryptionKey) {
|
||||
console.warn('⚠️ WARNING: No DATABASE_ENCRYPTION_KEY found!');
|
||||
console.warn('⚠️ Generating a random key for this session (not persistent).');
|
||||
console.warn('⚠️ For production, set DATABASE_ENCRYPTION_KEY environment variable.');
|
||||
console.warn('⚠️ Generate one with: openssl rand -hex 32');
|
||||
const generatedKey = crypto.randomBytes(32).toString('hex');
|
||||
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
|
||||
encryptionKey = generatedKey;
|
||||
try {
|
||||
const keyDir = path.dirname(KEY_FILE);
|
||||
if (!fs.existsSync(keyDir)) fs.mkdirSync(keyDir, { recursive: true });
|
||||
fs.writeFileSync(KEY_FILE, generatedKey, { mode: 0o600 });
|
||||
console.log('⚠️ Saved generated key to:', KEY_FILE);
|
||||
} catch (err) {
|
||||
console.warn('⚠️ Failed to persist encryption key:', err.message);
|
||||
}
|
||||
console.log('✅ Generated temporary encryption key');
|
||||
} else {
|
||||
console.log('✅ Using encryption key from environment');
|
||||
@@ -41,7 +63,7 @@ async function setupDatabase() {
|
||||
|
||||
// Initialize encryption
|
||||
try {
|
||||
initEncryption(process.env.DATABASE_ENCRYPTION_KEY);
|
||||
initEncryption(encryptionKey);
|
||||
console.log('✅ Encryption initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize encryption:', error.message);
|
||||
@@ -52,7 +74,10 @@ async function setupDatabase() {
|
||||
try {
|
||||
initDatabase(DATABASE_PATH, {
|
||||
verbose: false,
|
||||
walMode: WAL_MODE
|
||||
walMode: WAL_MODE,
|
||||
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
console.log('✅ Database initialized');
|
||||
} catch (error) {
|
||||
@@ -70,7 +95,15 @@ async function setupDatabase() {
|
||||
// Execute the entire schema as one block
|
||||
// SQLite can handle multiple statements with exec()
|
||||
db.exec(schema);
|
||||
|
||||
|
||||
// Add missing columns if this is an upgraded database
|
||||
const userColumns = db.prepare('PRAGMA table_info(users)').all();
|
||||
const userColumnNames = new Set(userColumns.map((c) => c.name));
|
||||
if (!userColumnNames.has('data')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN data TEXT');
|
||||
console.log('✅ Added users.data column');
|
||||
}
|
||||
|
||||
console.log('✅ Database schema created');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create schema:', error.message);
|
||||
|
||||
69
chat/scripts/verify-migration.js
Normal file
69
chat/scripts/verify-migration.js
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify JSON -> Database migration counts
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
|
||||
const { initEncryption } = require('../src/utils/encryption');
|
||||
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
|
||||
|
||||
function loadJson(filePath, fallback) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw || JSON.stringify(fallback));
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKey() {
|
||||
if (process.env.DATABASE_ENCRYPTION_KEY) return process.env.DATABASE_ENCRYPTION_KEY.trim();
|
||||
if (KEY_FILE && fs.existsSync(KEY_FILE)) {
|
||||
const key = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (key) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = key;
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const key = resolveKey();
|
||||
if (!key) {
|
||||
console.error('❌ DATABASE_ENCRYPTION_KEY not set (and no key file found).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
initEncryption(key);
|
||||
const useSqlcipher = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
initDatabase(DB_PATH, {
|
||||
sqlcipherKey: useSqlcipher ? key : null,
|
||||
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
|
||||
kdfIter: process.env.DATABASE_KDF_ITER || 64000
|
||||
});
|
||||
|
||||
const db = getDatabase();
|
||||
const now = Date.now();
|
||||
|
||||
const usersJson = loadJson(path.join(STATE_DIR, 'users.json'), []);
|
||||
const sessionsJson = loadJson(path.join(STATE_DIR, 'user-sessions.json'), {});
|
||||
const affiliatesJson = loadJson(path.join(STATE_DIR, 'affiliates.json'), []);
|
||||
|
||||
const usersDb = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const sessionsDb = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE expires_at > ?').get(now).count;
|
||||
const affiliatesDb = db.prepare('SELECT COUNT(*) as count FROM affiliate_accounts').get().count;
|
||||
|
||||
const activeJsonSessions = Object.values(sessionsJson || {}).filter((session) => session?.expiresAt && session.expiresAt > now).length;
|
||||
|
||||
console.log('Migration verification:');
|
||||
console.log(` Users: JSON=${Array.isArray(usersJson) ? usersJson.length : 0} DB=${usersDb}`);
|
||||
console.log(` Sessions (active): JSON=${activeJsonSessions} DB=${sessionsDb}`);
|
||||
console.log(` Affiliates: JSON=${Array.isArray(affiliatesJson) ? affiliatesJson.length : 0} DB=${affiliatesDb}`);
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
||||
main();
|
||||
505
chat/server.js
505
chat/server.js
@@ -18,6 +18,9 @@ const security = require('./security');
|
||||
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
|
||||
const blogSystem = require('./blog-system');
|
||||
const versionManager = require('./src/utils/versionManager');
|
||||
const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config');
|
||||
const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection');
|
||||
const { initEncryption, encrypt } = require('./src/utils/encryption');
|
||||
|
||||
let sharp = null;
|
||||
try {
|
||||
@@ -100,8 +103,11 @@ function detectDockerLimits(baseMemoryBytes, baseCpuCores, repoRoot) {
|
||||
|
||||
const PORT = Number(process.env.CHAT_PORT || 4000);
|
||||
const HOST = process.env.CHAT_HOST || '0.0.0.0';
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
|
||||
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
|
||||
const USE_JSON_DATABASE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
|
||||
const DATABASE_WAL_MODE = process.env.DATABASE_WAL_MODE !== '0' && process.env.DATABASE_WAL_MODE !== 'false';
|
||||
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
|
||||
const DATABASE_CIPHER_COMPAT = process.env.DATABASE_CIPHER_COMPAT || 4;
|
||||
const DATABASE_KDF_ITER = process.env.DATABASE_KDF_ITER || 64000;
|
||||
const STATE_FILE = path.join(STATE_DIR, 'sessions.json');
|
||||
const WORKSPACES_ROOT = path.join(DATA_ROOT, 'apps');
|
||||
const STATIC_ROOT = path.join(__dirname, 'public');
|
||||
@@ -1574,6 +1580,8 @@ const apiRateLimit = new Map(); // { userId: { requests, windowStart } }
|
||||
const csrfTokens = new Map(); // { token: { userId, expiresAt } }
|
||||
let usersDb = []; // In-memory user database cache
|
||||
let invoicesDb = []; // In-memory invoice database cache
|
||||
let databaseEnabled = false;
|
||||
let databaseFallbackReason = '';
|
||||
let mailTransport = null;
|
||||
|
||||
function summarizeMailConfig() {
|
||||
@@ -2154,9 +2162,197 @@ function requireAdminAuth(req, res) {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function loadDatabaseKey() {
|
||||
if (process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
return process.env.DATABASE_ENCRYPTION_KEY.trim();
|
||||
}
|
||||
try {
|
||||
if (KEY_FILE && fsSync.existsSync(KEY_FILE)) {
|
||||
const key = fsSync.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (key) {
|
||||
process.env.DATABASE_ENCRYPTION_KEY = key;
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (_) { }
|
||||
return '';
|
||||
}
|
||||
|
||||
async function initializeDatabaseLayer() {
|
||||
if (USE_JSON_DATABASE) {
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'USE_JSON_DATABASE';
|
||||
log('Database disabled via USE_JSON_DATABASE, using JSON storage');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbExists = fsSync.existsSync(DB_PATH);
|
||||
const key = await loadDatabaseKey();
|
||||
if (!key) {
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = dbExists ? 'missing_key' : 'no_key';
|
||||
if (dbExists) {
|
||||
log('DATABASE_ENCRYPTION_KEY missing; falling back to JSON mode', { DB_PATH, KEY_FILE });
|
||||
} else {
|
||||
log('No database key and no database found; using JSON mode', { DB_PATH });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
initEncryption(key);
|
||||
} catch (error) {
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'encryption_init_failed';
|
||||
log('Failed to initialize encryption; falling back to JSON mode', { error: String(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
initDatabase(DB_PATH, {
|
||||
walMode: DATABASE_WAL_MODE,
|
||||
sqlcipherKey: DATABASE_USE_SQLCIPHER ? key : null,
|
||||
cipherCompatibility: DATABASE_CIPHER_COMPAT,
|
||||
kdfIter: DATABASE_KDF_ITER
|
||||
});
|
||||
ensureDatabaseSchema();
|
||||
databaseEnabled = true;
|
||||
databaseFallbackReason = '';
|
||||
log('Database mode enabled', { dbPath: DB_PATH, sqlcipher: DATABASE_USE_SQLCIPHER });
|
||||
} catch (error) {
|
||||
closeDatabase();
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'db_init_failed';
|
||||
log('Failed to initialize database; falling back to JSON mode', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDatabaseSchema() {
|
||||
const db = getDatabase();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const userColumns = db.prepare('PRAGMA table_info(users)').all();
|
||||
const userColumnNames = new Set(userColumns.map((c) => c.name));
|
||||
if (!userColumnNames.has('data')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN data TEXT');
|
||||
log('Added users.data column');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS affiliate_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
codes TEXT NOT NULL DEFAULT '[]',
|
||||
earnings TEXT NOT NULL DEFAULT '[]',
|
||||
commission_rate REAL NOT NULL DEFAULT 0.15,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
last_payout_at TEXT,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at TEXT
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_affiliate_accounts_email ON affiliate_accounts(email);');
|
||||
} catch (error) {
|
||||
log('Failed to ensure database schema', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
// User authentication functions
|
||||
async function loadUsersDb() {
|
||||
let shouldPersist = false;
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const rows = db.prepare('SELECT * FROM users').all();
|
||||
usersDb = rows.map((row) => {
|
||||
let payload = {};
|
||||
if (row?.data) {
|
||||
try {
|
||||
payload = JSON.parse(row.data);
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
const user = { ...payload };
|
||||
user.id = row.id;
|
||||
user.email = row.email || user.email || '';
|
||||
user.password = row.password_hash || user.password || '';
|
||||
if (row.providers) {
|
||||
try {
|
||||
user.providers = JSON.parse(row.providers);
|
||||
} catch (_) {
|
||||
user.providers = Array.isArray(user.providers) ? user.providers : [];
|
||||
}
|
||||
} else {
|
||||
user.providers = Array.isArray(user.providers) ? user.providers : [];
|
||||
}
|
||||
user.emailVerified = typeof user.emailVerified === 'boolean' ? user.emailVerified : Boolean(row.email_verified);
|
||||
user.verificationToken = row.verification_token || user.verificationToken || '';
|
||||
user.verificationExpiresAt = row.verification_expires_at || user.verificationExpiresAt || null;
|
||||
user.resetToken = row.reset_token || user.resetToken || '';
|
||||
user.resetExpiresAt = row.reset_expires_at || user.resetExpiresAt || null;
|
||||
user.plan = row.plan || user.plan || null;
|
||||
user.billingStatus = row.billing_status || user.billingStatus || DEFAULT_BILLING_STATUS;
|
||||
user.billingEmail = row.billing_email || user.billingEmail || user.email || '';
|
||||
user.paymentMethodLast4 = row.payment_method_last4 || user.paymentMethodLast4 || '';
|
||||
user.subscriptionRenewsAt = row.subscription_renews_at || user.subscriptionRenewsAt || null;
|
||||
user.referredByAffiliateCode = sanitizeAffiliateCode(row.referred_by_affiliate_code || user.referredByAffiliateCode);
|
||||
user.affiliateAttributionAt = row.affiliate_attribution_at || user.affiliateAttributionAt || null;
|
||||
if (row.affiliate_payouts) {
|
||||
try {
|
||||
user.affiliatePayouts = JSON.parse(row.affiliate_payouts);
|
||||
} catch (_) {
|
||||
user.affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||||
}
|
||||
} else {
|
||||
user.affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||||
}
|
||||
user.twoFactorSecret = row.two_factor_secret || user.twoFactorSecret || null;
|
||||
user.twoFactorEnabled = typeof user.twoFactorEnabled === 'boolean' ? user.twoFactorEnabled : Boolean(row.two_factor_enabled);
|
||||
user.createdAt = row.created_at || user.createdAt || null;
|
||||
user.updatedAt = row.updated_at || user.updatedAt || null;
|
||||
user.lastLoginAt = row.last_login_at || user.lastLoginAt || null;
|
||||
|
||||
const verification = normalizeVerificationState(user);
|
||||
if (verification.shouldPersist) shouldPersist = true;
|
||||
const normalizedPlan = normalizePlanSelection(user?.plan) || DEFAULT_PLAN;
|
||||
return {
|
||||
...user,
|
||||
emailVerified: verification.verified,
|
||||
verificationToken: verification.verificationToken,
|
||||
verificationExpiresAt: verification.verificationExpiresAt,
|
||||
plan: normalizedPlan,
|
||||
billingStatus: user?.billingStatus || DEFAULT_BILLING_STATUS,
|
||||
billingEmail: user?.billingEmail || user?.email || '',
|
||||
paymentMethodLast4: user?.paymentMethodLast4 || '',
|
||||
subscriptionRenewsAt: user?.subscriptionRenewsAt || null,
|
||||
referredByAffiliateCode: sanitizeAffiliateCode(user?.referredByAffiliateCode),
|
||||
affiliateAttributionAt: user?.affiliateAttributionAt || null,
|
||||
affiliatePayouts: Array.isArray(user?.affiliatePayouts)
|
||||
? user.affiliatePayouts.map((p) => normalizePlanSelection(p)).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
});
|
||||
if (shouldPersist) {
|
||||
await persistUsersDb();
|
||||
}
|
||||
log('Loaded users database', { count: usersDb.length, mode: 'database' });
|
||||
return;
|
||||
} catch (error) {
|
||||
log('Failed to load users database from DB, falling back to JSON', { error: String(error) });
|
||||
closeDatabase();
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'load_failed';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStateFile();
|
||||
await fs.access(USERS_DB_FILE);
|
||||
@@ -2198,12 +2394,136 @@ async function loadUsersDb() {
|
||||
}
|
||||
|
||||
async function persistUsersDb() {
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, email_encrypted, name, name_encrypted, password_hash,
|
||||
providers, email_verified, verification_token, verification_expires_at,
|
||||
reset_token, reset_expires_at, plan, billing_status, billing_email,
|
||||
payment_method_last4, subscription_renews_at, referred_by_affiliate_code,
|
||||
affiliate_attribution_at, affiliate_payouts, two_factor_secret,
|
||||
two_factor_enabled, created_at, updated_at, last_login_at, data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
email_encrypted = excluded.email_encrypted,
|
||||
name = excluded.name,
|
||||
name_encrypted = excluded.name_encrypted,
|
||||
password_hash = excluded.password_hash,
|
||||
providers = excluded.providers,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at,
|
||||
reset_token = excluded.reset_token,
|
||||
reset_expires_at = excluded.reset_expires_at,
|
||||
plan = excluded.plan,
|
||||
billing_status = excluded.billing_status,
|
||||
billing_email = excluded.billing_email,
|
||||
payment_method_last4 = excluded.payment_method_last4,
|
||||
subscription_renews_at = excluded.subscription_renews_at,
|
||||
referred_by_affiliate_code = excluded.referred_by_affiliate_code,
|
||||
affiliate_attribution_at = excluded.affiliate_attribution_at,
|
||||
affiliate_payouts = excluded.affiliate_payouts,
|
||||
two_factor_secret = excluded.two_factor_secret,
|
||||
two_factor_enabled = excluded.two_factor_enabled,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
data = excluded.data
|
||||
`);
|
||||
|
||||
const ids = [];
|
||||
db.transaction(() => {
|
||||
for (const user of usersDb) {
|
||||
if (!user || !user.id) continue;
|
||||
ids.push(user.id);
|
||||
const normalizedEmail = (user.email || '').trim().toLowerCase();
|
||||
const passwordHash = user.passwordHash || user.password || '';
|
||||
const referredCode = sanitizeAffiliateCode(user.referredByAffiliateCode);
|
||||
const providers = Array.isArray(user.providers) ? user.providers : [];
|
||||
const affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
|
||||
const dataPayload = JSON.stringify({ ...user, email: normalizedEmail || user.email || '' });
|
||||
const emailEncrypted = normalizedEmail ? encrypt(normalizedEmail) : '';
|
||||
const nameEncrypted = user.name ? encrypt(user.name) : null;
|
||||
const twoFactorEncrypted = user.twoFactorSecret ? encrypt(user.twoFactorSecret) : null;
|
||||
|
||||
stmt.run(
|
||||
user.id,
|
||||
normalizedEmail,
|
||||
emailEncrypted,
|
||||
user.name || null,
|
||||
nameEncrypted,
|
||||
passwordHash,
|
||||
JSON.stringify(providers),
|
||||
user.emailVerified ? 1 : 0,
|
||||
user.verificationToken || null,
|
||||
user.verificationExpiresAt || null,
|
||||
user.resetToken || null,
|
||||
user.resetExpiresAt || null,
|
||||
user.plan || 'hobby',
|
||||
user.billingStatus || DEFAULT_BILLING_STATUS,
|
||||
user.billingEmail || user.email || '',
|
||||
user.paymentMethodLast4 || '',
|
||||
user.subscriptionRenewsAt || null,
|
||||
referredCode || null,
|
||||
user.affiliateAttributionAt || null,
|
||||
JSON.stringify(affiliatePayouts),
|
||||
twoFactorEncrypted,
|
||||
user.twoFactorEnabled ? 1 : 0,
|
||||
user.createdAt || now,
|
||||
user.updatedAt || user.createdAt || now,
|
||||
user.lastLoginAt || null,
|
||||
dataPayload
|
||||
);
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
db.prepare('DELETE FROM users').run();
|
||||
} else if (ids.length <= 900) {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM users WHERE id NOT IN (${placeholders})`).run(...ids);
|
||||
} else {
|
||||
log('Skipping users cleanup (too many rows for NOT IN)', { count: ids.length });
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
log('Failed to persist users database (DB)', { error: String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureStateFile();
|
||||
const payload = JSON.stringify(usersDb, null, 2);
|
||||
await safeWriteFile(USERS_DB_FILE, payload);
|
||||
}
|
||||
|
||||
async function loadUserSessions() {
|
||||
userSessions.clear();
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const now = Date.now();
|
||||
const rows = db.prepare('SELECT token, user_id, expires_at FROM sessions WHERE expires_at > ?').all(now);
|
||||
for (const row of rows) {
|
||||
if (row?.token && row?.user_id) {
|
||||
userSessions.set(row.token, { userId: row.user_id, expiresAt: row.expires_at });
|
||||
}
|
||||
}
|
||||
log('Loaded user sessions', { count: userSessions.size, mode: 'database' });
|
||||
return;
|
||||
} catch (error) {
|
||||
log('Failed to load user sessions from DB, falling back to JSON', { error: String(error) });
|
||||
closeDatabase();
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'load_failed';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStateFile();
|
||||
const raw = await fs.readFile(USER_SESSIONS_FILE, 'utf8').catch(() => null);
|
||||
@@ -2223,6 +2543,57 @@ async function loadUserSessions() {
|
||||
}
|
||||
|
||||
async function persistUserSessions() {
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const now = Date.now();
|
||||
const tokens = [];
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, user_id, token, refresh_token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at, last_accessed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
token = excluded.token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_accessed_at = excluded.last_accessed_at
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now);
|
||||
for (const [token, session] of userSessions.entries()) {
|
||||
if (!session.expiresAt || session.expiresAt <= now) continue;
|
||||
tokens.push(token);
|
||||
stmt.run(
|
||||
token,
|
||||
session.userId,
|
||||
token,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
session.expiresAt,
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
if (tokens.length === 0) {
|
||||
db.prepare('DELETE FROM sessions').run();
|
||||
} else if (tokens.length <= 900) {
|
||||
const placeholders = tokens.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM sessions WHERE token NOT IN (${placeholders})`).run(...tokens);
|
||||
} else {
|
||||
log('Skipping session cleanup (too many rows for NOT IN)', { count: tokens.length });
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
log('Failed to persist user sessions (DB)', { error: String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureStateFile();
|
||||
const now = Date.now();
|
||||
const sessions = {};
|
||||
@@ -2240,6 +2611,72 @@ function sanitizeAffiliateCode(code) {
|
||||
}
|
||||
|
||||
async function loadAffiliatesDb() {
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const rows = db.prepare('SELECT * FROM affiliate_accounts').all();
|
||||
affiliatesDb = rows.map((row) => ({
|
||||
id: row.id,
|
||||
email: (row.email || '').trim().toLowerCase(),
|
||||
name: row.name || row.email,
|
||||
password: row.password_hash || '',
|
||||
createdAt: row.created_at || new Date().toISOString(),
|
||||
lastLoginAt: row.last_login_at || null,
|
||||
commissionRate: Number.isFinite(row.commission_rate) ? row.commission_rate : AFFILIATE_COMMISSION_RATE,
|
||||
codes: (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(row.codes || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
earnings: (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(row.earnings || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
lastPayoutAt: row.last_payout_at || null,
|
||||
emailVerified: Boolean(row.email_verified),
|
||||
verificationToken: row.verification_token || '',
|
||||
verificationExpiresAt: row.verification_expires_at || null,
|
||||
updatedAt: row.updated_at || row.created_at || new Date().toISOString(),
|
||||
}));
|
||||
let changed = false;
|
||||
for (const affiliate of affiliatesDb) {
|
||||
affiliate.codes = Array.isArray(affiliate?.codes) && affiliate.codes.length
|
||||
? affiliate.codes.map((c) => ({
|
||||
code: sanitizeAffiliateCode(c.code || c.id || c.slug || ''),
|
||||
label: c.label || 'Tracking link',
|
||||
createdAt: c.createdAt || affiliate.createdAt || new Date().toISOString(),
|
||||
})).filter((c) => c.code)
|
||||
: [];
|
||||
affiliate.earnings = Array.isArray(affiliate?.earnings) ? affiliate.earnings : [];
|
||||
if (!affiliate.codes || !affiliate.codes.length) {
|
||||
const fallbackCode = generateTrackingCode();
|
||||
affiliate.codes = [{
|
||||
code: fallbackCode,
|
||||
label: 'Default link',
|
||||
createdAt: affiliate.createdAt || new Date().toISOString(),
|
||||
}];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await persistAffiliatesDb();
|
||||
log('Loaded affiliates database', { count: affiliatesDb.length, mode: 'database' });
|
||||
return;
|
||||
} catch (error) {
|
||||
log('Failed to load affiliates database from DB, falling back to JSON', { error: String(error) });
|
||||
closeDatabase();
|
||||
databaseEnabled = false;
|
||||
databaseFallbackReason = 'load_failed';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStateFile();
|
||||
let raw = '[]';
|
||||
@@ -2278,6 +2715,69 @@ async function loadAffiliatesDb() {
|
||||
}
|
||||
|
||||
async function persistAffiliatesDb() {
|
||||
if (databaseEnabled) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO affiliate_accounts (
|
||||
id, email, name, password_hash, codes, earnings, commission_rate,
|
||||
created_at, updated_at, last_login_at, last_payout_at,
|
||||
email_verified, verification_token, verification_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
name = excluded.name,
|
||||
password_hash = excluded.password_hash,
|
||||
codes = excluded.codes,
|
||||
earnings = excluded.earnings,
|
||||
commission_rate = excluded.commission_rate,
|
||||
updated_at = excluded.updated_at,
|
||||
last_login_at = excluded.last_login_at,
|
||||
last_payout_at = excluded.last_payout_at,
|
||||
email_verified = excluded.email_verified,
|
||||
verification_token = excluded.verification_token,
|
||||
verification_expires_at = excluded.verification_expires_at
|
||||
`);
|
||||
|
||||
const ids = [];
|
||||
db.transaction(() => {
|
||||
for (const affiliate of affiliatesDb) {
|
||||
if (!affiliate || !affiliate.id) continue;
|
||||
ids.push(affiliate.id);
|
||||
stmt.run(
|
||||
affiliate.id,
|
||||
(affiliate.email || '').trim().toLowerCase(),
|
||||
affiliate.name || '',
|
||||
affiliate.password || affiliate.passwordHash || '',
|
||||
JSON.stringify(Array.isArray(affiliate.codes) ? affiliate.codes : []),
|
||||
JSON.stringify(Array.isArray(affiliate.earnings) ? affiliate.earnings : []),
|
||||
Number.isFinite(affiliate.commissionRate) ? affiliate.commissionRate : AFFILIATE_COMMISSION_RATE,
|
||||
affiliate.createdAt || now,
|
||||
affiliate.updatedAt || affiliate.createdAt || now,
|
||||
affiliate.lastLoginAt || null,
|
||||
affiliate.lastPayoutAt || null,
|
||||
affiliate.emailVerified ? 1 : 0,
|
||||
affiliate.verificationToken || null,
|
||||
affiliate.verificationExpiresAt || null
|
||||
);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
db.prepare('DELETE FROM affiliate_accounts').run();
|
||||
} else if (ids.length <= 900) {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM affiliate_accounts WHERE id NOT IN (${placeholders})`).run(...ids);
|
||||
} else {
|
||||
log('Skipping affiliates cleanup (too many rows for NOT IN)', { count: ids.length });
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
log('Failed to persist affiliates database (DB)', { error: String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureStateFile();
|
||||
const payload = JSON.stringify(affiliatesDb, null, 2);
|
||||
await safeWriteFile(AFFILIATES_FILE, payload);
|
||||
@@ -19421,6 +19921,7 @@ async function bootstrap() {
|
||||
await loadSubscriptionSessions();
|
||||
await loadPendingSubscriptions();
|
||||
await loadInvoicesDb();
|
||||
await initializeDatabaseLayer();
|
||||
await loadUsersDb(); // Load user authentication database
|
||||
await loadUserSessions(); // Load user sessions
|
||||
await loadAffiliatesDb();
|
||||
|
||||
21
chat/src/database/config.js
Normal file
21
chat/src/database/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
|
||||
function resolvePath(root, value, fallback) {
|
||||
if (!value) return fallback;
|
||||
if (path.isAbsolute(value)) return value;
|
||||
return path.join(root, value);
|
||||
}
|
||||
|
||||
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
|
||||
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
|
||||
const DEFAULT_DB_PATH = path.join(DATA_ROOT, '.data', 'shopify_ai.db');
|
||||
const DB_PATH = resolvePath(DATA_ROOT, process.env.DATABASE_PATH, DEFAULT_DB_PATH);
|
||||
const DEFAULT_KEY_FILE = path.join(path.dirname(DB_PATH), '.encryption_key');
|
||||
const KEY_FILE = resolvePath(DATA_ROOT, process.env.DATABASE_KEY_FILE, DEFAULT_KEY_FILE);
|
||||
|
||||
module.exports = {
|
||||
DATA_ROOT,
|
||||
STATE_DIR,
|
||||
DB_PATH,
|
||||
KEY_FILE
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Database connection module with SQLite support
|
||||
* Uses better-sqlite3 for synchronous operations
|
||||
* Note: SQLCipher support requires special compilation, using AES-256-GCM encryption at field level instead
|
||||
* Note: SQLCipher support requires special compilation and is enabled via configuration
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -11,6 +11,10 @@ const fs = require('fs');
|
||||
let db = null;
|
||||
let dbPath = null;
|
||||
|
||||
function escapeSqliteString(value) {
|
||||
return String(value || '').replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
* @param {string} databasePath - Path to the database file
|
||||
@@ -43,6 +47,18 @@ function initDatabase(databasePath, options = {}) {
|
||||
|
||||
db = new Database(databasePath, dbOptions);
|
||||
|
||||
// SQLCipher support (optional)
|
||||
if (options.sqlcipherKey) {
|
||||
const escapedKey = escapeSqliteString(options.sqlcipherKey);
|
||||
db.pragma(`key = '${escapedKey}'`);
|
||||
if (options.cipherCompatibility) {
|
||||
db.pragma(`cipher_compatibility = ${Number(options.cipherCompatibility)}`);
|
||||
}
|
||||
if (options.kdfIter) {
|
||||
db.pragma(`kdf_iter = ${Number(options.kdfIter)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
if (options.walMode !== false) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
two_factor_enabled INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
last_login_at INTEGER,
|
||||
data TEXT -- Full user payload (JSON)
|
||||
);
|
||||
|
||||
-- Sessions table for active user sessions
|
||||
@@ -92,6 +93,24 @@ CREATE TABLE IF NOT EXISTS affiliates (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Affiliate accounts (used by current app)
|
||||
CREATE TABLE IF NOT EXISTS affiliate_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
codes TEXT NOT NULL DEFAULT '[]', -- JSON array of tracking codes
|
||||
earnings TEXT NOT NULL DEFAULT '[]', -- JSON array of earnings
|
||||
commission_rate REAL NOT NULL DEFAULT 0.15,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
last_payout_at TEXT,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at TEXT
|
||||
);
|
||||
|
||||
-- Withdrawals table
|
||||
CREATE TABLE IF NOT EXISTS withdrawals (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -172,6 +191,7 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_token_jti ON token_blacklist(token_jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires_at ON token_blacklist(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliates_user_id ON affiliates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliate_accounts_email ON affiliate_accounts(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_withdrawals_affiliate_id ON withdrawals(affiliate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_user_id ON feature_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||
|
||||
Reference in New Issue
Block a user