-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdatabase.php
More file actions
437 lines (393 loc) · 18.9 KB
/
database.php
File metadata and controls
437 lines (393 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
<?php
// Database helper functions
// Load config.php if it exists, otherwise use defaults
if (file_exists(__DIR__ . '/config.php')) {
require_once 'config.php';
}
// Define DB_PATH if not already defined
if (!defined('DB_PATH')) {
// Auto-detect environment
$isLocalhost = false;
if (getenv('COMMENT_ENV') === 'development') {
$isLocalhost = true;
} elseif (isset($_SERVER['HTTP_HOST'])) {
$host = $_SERVER['HTTP_HOST'];
$isLocalhost = (
strpos($host, 'localhost') !== false ||
strpos($host, '127.0.0.1') !== false ||
strpos($host, '.local') !== false ||
strpos($host, ':1313') !== false
);
} elseif (php_sapi_name() === 'cli-server') {
$isLocalhost = true;
}
define('DB_PATH', __DIR__ . ($isLocalhost ? '/db/comments-dev.db' : '/db/comments.db'));
}
function getDatabase() {
try {
$db = new PDO('sqlite:' . DB_PATH);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Enable foreign key constraints in SQLite
$db->exec('PRAGMA foreign_keys = ON');
// Set busy timeout to 30 seconds to handle database locks
$db->setAttribute(PDO::ATTR_TIMEOUT, 30);
$db->exec('PRAGMA busy_timeout = 30000');
// Enable WAL mode for better concurrency
$db->exec('PRAGMA journal_mode = WAL');
// Performance tuning
$db->exec('PRAGMA cache_size = -8000'); // 8MB page cache
$db->exec('PRAGMA temp_store = MEMORY'); // temp tables in RAM
$db->exec('PRAGMA mmap_size = 134217728'); // 128MB memory-mapped I/O
return $db;
} catch (PDOException $e) {
error_log('Database connection failed: ' . $e->getMessage());
return null;
}
}
function tableExists($db, $tableName) {
try {
$stmt = $db->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?");
$stmt->execute([$tableName]);
return $stmt->fetch() !== false;
} catch (PDOException $e) {
return false;
}
}
function initDatabase() {
$db = getDatabase();
if (!$db) return false;
// Check if schema.sql exists, otherwise use inline schema
$schemaFile = __DIR__ . '/utils/schema.sql';
if (file_exists($schemaFile)) {
$schema = file_get_contents($schemaFile);
} else {
// Inline schema for deployment
$schema = "
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT NOT NULL,
parent_id INTEGER DEFAULT NULL,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
author_url TEXT DEFAULT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'spam', 'deleted')),
ip_address TEXT,
user_agent TEXT,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_page_url ON comments(page_url);
CREATE INDEX IF NOT EXISTS idx_parent_id ON comments(parent_id);
CREATE INDEX IF NOT EXISTS idx_status ON comments(status);
CREATE INDEX IF NOT EXISTS idx_created_at ON comments(created_at);
CREATE INDEX IF NOT EXISTS idx_ip_address ON comments(ip_address);
CREATE INDEX IF NOT EXISTS idx_author_email ON comments(author_email);
CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON comments(ip_address, created_at);
CREATE INDEX IF NOT EXISTS idx_rate_limit_email ON comments(author_email, created_at);
CREATE INDEX IF NOT EXISTS idx_page_url_status ON comments(page_url, status);
CREATE INDEX IF NOT EXISTS idx_author_email_status ON comments(author_email, status);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('admin_password_hash', ''),
('require_moderation', 'true'),
('allow_guest_comments', 'true'),
('max_comment_length', '5000'),
('enable_notifications', 'false'),
('admin_email', ''),
('schema_version', '0');
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT NOT NULL,
email TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
active INTEGER DEFAULT 1,
UNIQUE(page_url, email)
);
CREATE INDEX IF NOT EXISTS idx_sub_page_url ON subscriptions(page_url);
CREATE INDEX IF NOT EXISTS idx_sub_email ON subscriptions(email);
CREATE INDEX IF NOT EXISTS idx_sub_token ON subscriptions(token);
CREATE TABLE IF NOT EXISTS email_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER,
recipient_email TEXT NOT NULL,
recipient_name TEXT,
email_type TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed')),
attempts INTEGER DEFAULT 0,
last_error TEXT,
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, created_at);
CREATE INDEX IF NOT EXISTS idx_email_queue_comment ON email_queue(comment_id);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
success INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address, attempted_at);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_session_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_session_expires ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
reaction_type TEXT NOT NULL DEFAULT 'heart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
UNIQUE(comment_id, ip_address, reaction_type)
);
CREATE INDEX IF NOT EXISTS idx_votes_comment ON votes(comment_id);
CREATE TABLE IF NOT EXISTS vote_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_vote_log_ip ON vote_log(ip_address, created_at);
CREATE TABLE IF NOT EXISTS post_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT NOT NULL,
ip_address TEXT NOT NULL,
reaction_type TEXT NOT NULL DEFAULT 'heart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_url, ip_address, reaction_type)
);
CREATE INDEX IF NOT EXISTS idx_post_reactions_page ON post_reactions(page_url);
";
}
try {
$db->exec($schema);
return true;
} catch (PDOException $e) {
error_log('Database initialization failed: ' . $e->getMessage());
return false;
}
}
// Bump this when adding new migrations so existing installs skip the full migration block
define('CURRENT_SCHEMA_VERSION', '2');
function migrateDatabase() {
$db = getDatabase();
if (!$db) return false;
try {
// Fast path: if schema is already current, skip all migration checks
$versionStmt = $db->query("SELECT value FROM settings WHERE key = 'schema_version'");
$storedVersion = $versionStmt ? $versionStmt->fetchColumn() : '0';
if ($storedVersion === CURRENT_SCHEMA_VERSION) {
// Still run probabilistic cleanup even on fast path
if (rand(1, 20) === 1) {
$db->exec("DELETE FROM vote_log WHERE created_at < datetime('now', '-1 hour')");
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-7 days')");
}
return true;
}
// Check if subscriptions table exists, if not create it
if (!tableExists($db, 'subscriptions')) {
$db->exec("
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT NOT NULL,
email TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
active INTEGER DEFAULT 1,
UNIQUE(page_url, email)
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sub_page_url ON subscriptions(page_url)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sub_email ON subscriptions(email)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sub_token ON subscriptions(token)");
error_log('Database migration: subscriptions table created');
}
// Add performance indexes (safe to run multiple times due to IF NOT EXISTS)
$db->exec("CREATE INDEX IF NOT EXISTS idx_ip_address ON comments(ip_address)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_author_email ON comments(author_email)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON comments(ip_address, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_rate_limit_email ON comments(author_email, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_page_url_status ON comments(page_url, status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_author_email_status ON comments(author_email, status)");
// Create email queue table if it doesn't exist
if (!tableExists($db, 'email_queue')) {
$db->exec("
CREATE TABLE IF NOT EXISTS email_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER,
recipient_email TEXT NOT NULL,
recipient_name TEXT,
email_type TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed')),
attempts INTEGER DEFAULT 0,
last_error TEXT,
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_email_queue_comment ON email_queue(comment_id)");
error_log('Database migration: email_queue table created');
}
// Create login attempts table if it doesn't exist
if (!tableExists($db, 'login_attempts')) {
$db->exec("
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
success INTEGER DEFAULT 0
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address, attempted_at)");
error_log('Database migration: login_attempts table created');
}
// Create sessions table if it doesn't exist
if (!tableExists($db, 'sessions')) {
$db->exec("
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT,
user_agent TEXT
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_session_token ON sessions(token)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_session_expires ON sessions(expires_at)");
error_log('Database migration: sessions table created');
}
// Create votes table if it doesn't exist
if (!tableExists($db, 'votes')) {
$db->exec("
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
reaction_type TEXT NOT NULL DEFAULT 'heart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
UNIQUE(comment_id, ip_address, reaction_type)
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_votes_comment ON votes(comment_id)");
error_log('Database migration: votes table created');
} else {
// Migrate existing votes table to add reaction_type if missing
$pragma = $db->query("PRAGMA table_info(votes)")->fetchAll();
$hasReactionType = false;
foreach ($pragma as $col) {
if ($col['name'] === 'reaction_type') { $hasReactionType = true; break; }
}
if (!$hasReactionType) {
$db->exec("
CREATE TABLE votes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
reaction_type TEXT NOT NULL DEFAULT 'heart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
UNIQUE(comment_id, ip_address, reaction_type)
)
");
$db->exec("INSERT INTO votes_new (id, comment_id, ip_address, reaction_type, created_at)
SELECT id, comment_id, ip_address, 'heart', created_at FROM votes");
$db->exec("DROP TABLE votes");
$db->exec("ALTER TABLE votes_new RENAME TO votes");
$db->exec("CREATE INDEX IF NOT EXISTS idx_votes_comment ON votes(comment_id)");
error_log('Database migration: votes table updated with reaction_type');
}
}
// Create vote_log table if it doesn't exist
if (!tableExists($db, 'vote_log')) {
$db->exec("
CREATE TABLE IF NOT EXISTS vote_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_vote_log_ip ON vote_log(ip_address, created_at)");
error_log('Database migration: vote_log table created');
}
// Create post_reactions table if it doesn't exist
if (!tableExists($db, 'post_reactions')) {
$db->exec("
CREATE TABLE IF NOT EXISTS post_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_url TEXT NOT NULL,
ip_address TEXT NOT NULL,
reaction_type TEXT NOT NULL DEFAULT 'heart',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_url, ip_address, reaction_type)
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_post_reactions_page ON post_reactions(page_url)");
error_log('Database migration: post_reactions table created');
}
// Clean up old vote_log entries (5% chance)
if (rand(1, 20) === 1) {
$db->exec("DELETE FROM vote_log WHERE created_at < datetime('now', '-1 hour')");
}
// Clean up expired sessions (5% chance to avoid overhead on every request)
if (rand(1, 20) === 1) {
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
}
// Clean up old login attempts (5% chance)
if (rand(1, 20) === 1) {
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-7 days')");
}
// Update query planner statistics and mark schema as current
$db->exec("ANALYZE");
$db->exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('schema_version', '" . CURRENT_SCHEMA_VERSION . "')");
return true;
} catch (PDOException $e) {
error_log('Database migration failed: ' . $e->getMessage());
return false;
}
}
// Initialize database if it doesn't exist
if (!file_exists(DB_PATH)) {
// Ensure db directory exists
$dbDir = dirname(DB_PATH);
if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true);
}
// Check if a default template database exists
$defaultDbPath = __DIR__ . '/db/comments-default.db';
if (file_exists($defaultDbPath)) {
// Copy the default database instead of creating from scratch
copy($defaultDbPath, DB_PATH);
error_log('Database initialized from db/comments-default.db template');
} else {
// Create new database from schema
initDatabase();
error_log('Database initialized from schema');
}
} else {
// Run migrations on existing database
migrateDatabase();
}