@riligar/elysia-sqlite 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,553 @@
1
+ import { Elysia } from "elysia";
2
+ import { Database } from "bun:sqlite";
3
+ import { join } from "path";
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { writeFile } from 'node:fs/promises';
6
+ import { createSessionManager } from './core/session.js';
7
+ import { authenticator } from 'otplib';
8
+ import QRCode from 'qrcode';
9
+
10
+ // Mapeamento de extensões para MIME types
11
+ const mimeTypes = {
12
+ ".html": "text/html",
13
+ ".js": "application/javascript",
14
+ ".css": "text/css",
15
+ ".json": "application/json",
16
+ ".png": "image/png",
17
+ ".svg": "image/svg+xml",
18
+ ".ico": "image/x-icon",
19
+ };
20
+
21
+ /**
22
+ * Plugin de administração SQLite para ElysiaJS
23
+ * @param {Object} config - Configuração do plugin
24
+ * @param {string} config.dbPath - Caminho para o arquivo do banco SQLite
25
+ * @param {string} config.prefix - Prefixo da rota (ex: '/admin')
26
+ * @param {string} config.configPath - Caminho para o arquivo de configuração de auth (ex: './sqlite-admin-config.json')
27
+ */
28
+ export const sqliteAdmin = ({ dbPath, prefix = "/admin", configPath = "./sqlite-admin-config.json" }) => {
29
+ const db = new Database(dbPath);
30
+ const uiPath = join(import.meta.dir, "ui", "dist");
31
+
32
+ // Gerenciador de Sessão
33
+ const sessionManager = createSessionManager();
34
+
35
+ // Carregar Configuração
36
+ let config = {};
37
+ const loadConfig = () => {
38
+ if (existsSync(configPath)) {
39
+ try {
40
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
41
+ } catch (e) {
42
+ console.error("Failed to load config", e);
43
+ }
44
+ }
45
+ };
46
+ loadConfig();
47
+
48
+ const saveConfig = async (newConfig) => {
49
+ config = { ...config, ...newConfig };
50
+ try {
51
+ await writeFile(configPath, JSON.stringify(config, null, 2));
52
+ return true;
53
+ } catch (e) {
54
+ console.error("Failed to save config", e);
55
+ return false;
56
+ }
57
+ };
58
+
59
+ const isConfigured = () => !!(config.username && config.password);
60
+
61
+ return (
62
+ new Elysia({ prefix })
63
+ // Middleware de Autenticação
64
+ .derive(({ headers }) => {
65
+ const cookies = headers.cookie || '';
66
+ const sessionMatch = cookies.match(/admin-session=([^;]+)/);
67
+ const token = sessionMatch ? sessionMatch[1] : null;
68
+ const session = sessionManager.get(token);
69
+ return { session };
70
+ })
71
+ .onBeforeHandle(({ path, set, session, body }) => {
72
+ // Permitir assets e HTML principal
73
+ if (path.includes('/assets/') || path === prefix || path === prefix + '/') return;
74
+ if (path.endsWith('index.html')) return;
75
+
76
+ // Rotas Públicas de API
77
+ if (path.endsWith('/auth/login') || path.endsWith('/auth/status') || path.endsWith('/auth/logout')) return;
78
+
79
+ // Rota de Setup (só permitida se não configurado)
80
+ if (path.endsWith('/api/setup')) {
81
+ if (isConfigured()) {
82
+ set.status = 403;
83
+ return { success: false, error: "System already configured" };
84
+ }
85
+ return;
86
+ }
87
+
88
+ // Para todas as outras rotas /api/, exigir configuração e autenticação
89
+ if (path.includes('/api/')) {
90
+ if (!isConfigured()) {
91
+ set.status = 403;
92
+ return { success: false, error: "System not configured", code: "NOT_CONFIGURED" };
93
+ }
94
+
95
+ if (!session) {
96
+ set.status = 401;
97
+ return { success: false, error: "Unauthorized", code: "UNAUTHORIZED" };
98
+ }
99
+ }
100
+ })
101
+
102
+ // AUTH: Status
103
+ .get("/auth/status", ({ session }) => {
104
+ return {
105
+ configured: isConfigured(),
106
+ authenticated: !!session,
107
+ user: session?.username,
108
+ totpEnabled: !!config.totpSecret
109
+ };
110
+ })
111
+
112
+ // AUTH: Setup (Onboarding)
113
+ .post("/api/setup", async ({ body }) => {
114
+ if (isConfigured()) {
115
+ return { success: false, error: "Already configured" };
116
+ }
117
+ const { username, password } = body;
118
+ if (!username || !password) {
119
+ return { success: false, error: "Username and password required" };
120
+ }
121
+
122
+ if (await saveConfig({ username, password })) {
123
+ // Criar sessão automaticamente
124
+ const { token, expiresAt } = sessionManager.create(username);
125
+ const expiresDate = new Date(expiresAt);
126
+
127
+ return new Response(JSON.stringify({ success: true }), {
128
+ headers: {
129
+ 'Content-Type': 'application/json',
130
+ 'Set-Cookie': `admin-session=${token}; Path=${prefix}; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
131
+ }
132
+ });
133
+ }
134
+ return { success: false, error: "Failed to save config" };
135
+ })
136
+
137
+ // AUTH: Login
138
+ .post("/auth/login", ({ body, set }) => {
139
+ if (!isConfigured()) {
140
+ set.status = 403;
141
+ return { success: false, error: "Not configured" };
142
+ }
143
+
144
+ const { username, password, totpCode } = body;
145
+
146
+ if (username === config.username && password === config.password) {
147
+ // 2FA Verification
148
+ if (config.totpSecret) {
149
+ if (!totpCode) {
150
+ set.status = 401; // Require 2FA
151
+ return { success: false, error: "2FA code required", code: "2FA_REQUIRED" };
152
+ }
153
+
154
+ const isValid = authenticator.check(totpCode, config.totpSecret);
155
+ if (!isValid) {
156
+ set.status = 401;
157
+ return { success: false, error: "Invalid 2FA code" };
158
+ }
159
+ }
160
+
161
+ const { token, expiresAt } = sessionManager.create(username);
162
+ const expiresDate = new Date(expiresAt);
163
+
164
+ return new Response(JSON.stringify({ success: true }), {
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ 'Set-Cookie': `admin-session=${token}; Path=${prefix}; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
168
+ }
169
+ });
170
+ }
171
+
172
+ set.status = 401;
173
+ return { success: false, error: "Invalid credentials" };
174
+ })
175
+
176
+ // AUTH: Logout
177
+ .post("/auth/logout", ({ session }) => {
178
+ return new Response(JSON.stringify({ success: true }), {
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ 'Set-Cookie': `admin-session=; Path=${prefix}; HttpOnly; SameSite=Lax; Max-Age=0`
182
+ }
183
+ });
184
+ })
185
+
186
+ // TOTP: Generate
187
+ .post("/api/totp/generate", async ({ session, set }) => {
188
+ if (!session) { set.status = 401; return; }
189
+ const secret = authenticator.generateSecret();
190
+ const otpauth = authenticator.keyuri(session.username, 'SQLite Admin', secret);
191
+ const qrCode = await QRCode.toDataURL(otpauth);
192
+ return { success: true, secret, qrCode };
193
+ })
194
+
195
+ // TOTP: Verify & Enable
196
+ .post("/api/totp/verify", async ({ body, session, set }) => {
197
+ if (!session) { set.status = 401; return; }
198
+ const { secret, code } = body;
199
+
200
+ if (!authenticator.check(code, secret)) {
201
+ return { success: false, error: "Invalid code" };
202
+ }
203
+
204
+ if (await saveConfig({ totpSecret: secret })) {
205
+ return { success: true };
206
+ }
207
+ return { success: false, error: "Failed to save config" };
208
+ })
209
+
210
+ // TOTP: Disable
211
+ .post("/api/totp/disable", async ({ body, session, set }) => {
212
+ if (!session) { set.status = 401; return; }
213
+ const { code } = body; // Confirm with code before disabling
214
+
215
+ if (!authenticator.check(code, config.totpSecret)) {
216
+ return { success: false, error: "Invalid code" };
217
+ }
218
+
219
+ // Remove secret
220
+ const newConfig = { ...config };
221
+ delete newConfig.totpSecret;
222
+ config = newConfig; // Local update
223
+
224
+ try {
225
+ await writeFile(configPath, JSON.stringify(config, null, 2));
226
+ return { success: true };
227
+ } catch(e) {
228
+ return { success: false, error: "Failed to save" };
229
+ }
230
+ })
231
+
232
+ // Servir index.html na raiz
233
+ .get("/", async () => {
234
+ const file = Bun.file(join(uiPath, "index.html"));
235
+ return new Response(file, { headers: { "Content-Type": "text/html" } });
236
+ })
237
+
238
+ // Servir arquivos estáticos da pasta assets
239
+ .get("/assets/*", async ({ params }) => {
240
+ const filePath = join(uiPath, "assets", params["*"]);
241
+ const file = Bun.file(filePath);
242
+ if (!(await file.exists()))
243
+ return new Response("Not found", { status: 404 });
244
+ const ext = filePath.substring(filePath.lastIndexOf("."));
245
+ return new Response(file, {
246
+ headers: {
247
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
248
+ },
249
+ });
250
+ })
251
+
252
+ // Lista todas as tabelas do banco
253
+ .get("/api/tables", () => {
254
+ const tables = db
255
+ .query(
256
+ `
257
+ SELECT name FROM sqlite_master
258
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
259
+ ORDER BY name
260
+ `
261
+ )
262
+ .all();
263
+ return { tables: tables.map((t) => t.name) };
264
+ })
265
+
266
+ // Lista linhas de uma tabela com paginação
267
+ .get("/api/table/:name/rows", ({ params, query }) => {
268
+ try {
269
+ const page = parseInt(query.page) || 1;
270
+ const limit = parseInt(query.limit) || 50;
271
+ const offset = (page - 1) * limit;
272
+
273
+ const rows = db.query(`SELECT * FROM ${params.name} LIMIT ${limit} OFFSET ${offset}`).all();
274
+ const countResult = db.query(`SELECT COUNT(*) as count FROM ${params.name}`).get();
275
+ const total = countResult.count;
276
+
277
+ return {
278
+ success: true,
279
+ rows,
280
+ pagination: {
281
+ page,
282
+ limit,
283
+ total,
284
+ totalPages: Math.ceil(total / limit)
285
+ }
286
+ };
287
+ } catch (error) {
288
+ return { success: false, error: error.message };
289
+ }
290
+ })
291
+
292
+ // Executa query SQL arbitrária
293
+ .post("/api/query", ({ body }) => {
294
+ try {
295
+ const { sql } = body;
296
+ const isSelect = sql.trim().toLowerCase().startsWith("select");
297
+
298
+ if (isSelect) {
299
+ const results = db.query(sql).all();
300
+ const columns = results.length > 0 ? Object.keys(results[0]) : [];
301
+ return { success: true, columns, rows: results };
302
+ } else {
303
+ const result = db.run(sql);
304
+ return {
305
+ success: true,
306
+ message: `Query executada. ${result.changes} linha(s) afetada(s).`,
307
+ };
308
+ }
309
+ } catch (error) {
310
+ return { success: false, error: error.message };
311
+ }
312
+ })
313
+
314
+ // Conta total de registros de uma tabela
315
+ .get("/api/table/:name/count", ({ params }) => {
316
+ try {
317
+ const result = db
318
+ .query(`SELECT COUNT(*) as count FROM ${params.name}`)
319
+ .get();
320
+ return { success: true, count: result.count };
321
+ } catch (error) {
322
+ return { success: false, error: error.message };
323
+ }
324
+ })
325
+
326
+ // Insere um novo registro
327
+ .post("/api/table/:name/insert", ({ params, body }) => {
328
+ try {
329
+ const columns = Object.keys(body);
330
+ const placeholders = columns.map(() => "?").join(", ");
331
+ const values = Object.values(body);
332
+ const sql = `INSERT INTO ${params.name} (${columns.join(
333
+ ", "
334
+ )}) VALUES (${placeholders})`;
335
+ const result = db.run(sql, values);
336
+ return { success: true, id: result.lastInsertRowid };
337
+ } catch (error) {
338
+ return { success: false, error: error.message };
339
+ }
340
+ })
341
+
342
+ // Atualiza um registro (inline edit)
343
+ .post("/api/table/:name/update", ({ params, body }) => {
344
+ try {
345
+ const { column, value, pkColumn, pkValue } = body;
346
+ const sql = `UPDATE ${params.name} SET ${column} = ? WHERE ${pkColumn} = ?`;
347
+ const result = db.run(sql, [value, pkValue]);
348
+ return { success: true, changes: result.changes };
349
+ } catch (error) {
350
+ return { success: false, error: error.message };
351
+ }
352
+ })
353
+
354
+ // Exclui um registro
355
+ .post("/api/table/:name/delete", ({ params, body }) => {
356
+ try {
357
+ const { column, value } = body;
358
+ const sql = `DELETE FROM ${params.name} WHERE ${column} = ?`;
359
+ const result = db.run(sql, [value]);
360
+ return { success: true, changes: result.changes };
361
+ } catch (error) {
362
+ return { success: false, error: error.message };
363
+ }
364
+ })
365
+
366
+ // Retorna estrutura de uma tabela com foreign keys
367
+ .get("/api/table/:name", ({ params }) => {
368
+ try {
369
+ const info = db.query(`PRAGMA table_info(${params.name})`).all();
370
+ const foreignKeys = db
371
+ .query(`PRAGMA foreign_key_list(${params.name})`)
372
+ .all();
373
+
374
+ // Enrich columns with FK info
375
+ const columnsWithFK = info.map((col) => {
376
+ const fk = foreignKeys.find((f) => f.from === col.name);
377
+ if (fk) {
378
+ return {
379
+ ...col,
380
+ fk: {
381
+ table: fk.table,
382
+ column: fk.to,
383
+ },
384
+ };
385
+ }
386
+ return col;
387
+ });
388
+
389
+ return { success: true, columns: columnsWithFK };
390
+ } catch (error) {
391
+ return { success: false, error: error.message };
392
+ }
393
+ })
394
+
395
+ // Retorna opções para foreign key (valores da tabela referenciada)
396
+ .get("/api/table/:name/fk-options", ({ params, query }) => {
397
+ try {
398
+ const { refTable, refColumn } = query;
399
+ if (!refTable || !refColumn) {
400
+ return { success: false, error: "Missing refTable or refColumn" };
401
+ }
402
+
403
+ // Try to get a display column (first text column or the pk itself)
404
+ const tableInfo = db.query(`PRAGMA table_info(${refTable})`).all();
405
+ const displayCol =
406
+ tableInfo.find(
407
+ (c) =>
408
+ c.type?.toUpperCase().includes("TEXT") && c.name !== refColumn
409
+ )?.name || refColumn;
410
+
411
+ const sql = `SELECT ${refColumn} as value, ${displayCol} as label FROM ${refTable} ORDER BY ${displayCol}`;
412
+ const options = db.query(sql).all();
413
+
414
+ return { success: true, options };
415
+ } catch (error) {
416
+ return { success: false, error: error.message };
417
+ }
418
+ })
419
+
420
+ // Resolve specific FK IDs to display values
421
+ .post("/api/resolve-fk", ({ body }) => {
422
+ try {
423
+ const { table, idColumn, ids } = body;
424
+ if (
425
+ !table ||
426
+ !idColumn ||
427
+ !ids ||
428
+ !Array.isArray(ids) ||
429
+ ids.length === 0
430
+ ) {
431
+ return { success: true, values: {} };
432
+ }
433
+
434
+ // Identify display column
435
+ const tableInfo = db.query(`PRAGMA table_info(${table})`).all();
436
+ const displayCol =
437
+ tableInfo.find(
438
+ (c) =>
439
+ c.type?.toUpperCase().includes("TEXT") && c.name !== idColumn
440
+ )?.name || idColumn;
441
+
442
+ const placeholders = ids.map(() => "?").join(",");
443
+ // Handle potential duplicates in ids by using DISTINCT if needed, but Map handles it safely
444
+ const sql = `SELECT ${idColumn} as id, ${displayCol} as label FROM ${table} WHERE ${idColumn} IN (${placeholders})`;
445
+
446
+ const results = db.query(sql).all(...ids);
447
+
448
+ // Convert to map: { [id]: label }
449
+ const values = results.reduce((acc, row) => {
450
+ acc[row.id] = row.label;
451
+ return acc;
452
+ }, {});
453
+
454
+ return { success: true, values };
455
+ } catch (error) {
456
+ return { success: false, error: error.message };
457
+ }
458
+ })
459
+
460
+ // AI SQL Generation
461
+ .post("/api/ai/sql", async ({ body }) => {
462
+ try {
463
+ const { prompt } = body;
464
+ const apiKey = process.env.OPENROUTER_API_KEY;
465
+ const model =
466
+ process.env.OPENROUTER_MODEL || "meta-llama/llama-3.3-70b-instruct";
467
+
468
+ if (!apiKey) {
469
+ return {
470
+ success: false,
471
+ error: "OpenRouter API Key not configured",
472
+ };
473
+ }
474
+
475
+ // Get database schema context
476
+ const tables = db
477
+ .query(
478
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
479
+ )
480
+ .all();
481
+ let schemaContext = "";
482
+
483
+ for (const t of tables) {
484
+ const cols = db.query(`PRAGMA table_info(${t.name})`).all();
485
+ schemaContext += `Table ${t.name}: ${cols
486
+ .map((c) => c.name + "(" + c.type + ")")
487
+ .join(", ")}\n`;
488
+ }
489
+
490
+ const response = await fetch(
491
+ "https://openrouter.ai/api/v1/chat/completions",
492
+ {
493
+ method: "POST",
494
+ headers: {
495
+ Authorization: `Bearer ${apiKey}`,
496
+ "Content-Type": "application/json",
497
+ },
498
+ body: JSON.stringify({
499
+ model: model,
500
+ messages: [
501
+ {
502
+ role: "system",
503
+ content: `You are a SQLite expert. Given the following database schema:\n${schemaContext}\nGenerate a valid SQLite query for the user's request. Return ONLY the raw SQL query, no markdown formatting, no explanations.`,
504
+ },
505
+ {
506
+ role: "user",
507
+ content: prompt,
508
+ },
509
+ ],
510
+ }),
511
+ }
512
+ );
513
+
514
+ const data = await response.json();
515
+ const sql = data.choices?.[0]?.message?.content
516
+ ?.trim()
517
+ .replace(/```sql/g, "")
518
+ .replace(/```/g, "");
519
+
520
+ return { success: true, sql };
521
+ } catch (error) {
522
+ return { success: false, error: error.message };
523
+ }
524
+ })
525
+
526
+ // Get full database schema for ERD
527
+ .get("/api/meta/schema", () => {
528
+ try {
529
+ const tables = db
530
+ .query(
531
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
532
+ )
533
+ .all();
534
+ const schema = tables.map((t) => {
535
+ const columns = db.query(`PRAGMA table_info(${t.name})`).all();
536
+ const fks = db.query(`PRAGMA foreign_key_list(${t.name})`).all();
537
+ return {
538
+ name: t.name,
539
+ columns,
540
+ fks: fks.map((fk) => ({
541
+ from: fk.from,
542
+ table: fk.table,
543
+ to: fk.to,
544
+ })),
545
+ };
546
+ });
547
+ return { success: true, schema };
548
+ } catch (error) {
549
+ return { success: false, error: error.message };
550
+ }
551
+ })
552
+ );
553
+ };