postbase 0.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.
Files changed (126) hide show
  1. package/.github/workflows/test.yml +74 -0
  2. package/CLA.md +60 -0
  3. package/CONTRIBUTORS.md +35 -0
  4. package/LICENSE +661 -0
  5. package/README.md +211 -0
  6. package/admin/404.html +33 -0
  7. package/admin/README.md +21 -0
  8. package/admin/index.html +15 -0
  9. package/admin/jsconfig.json +20 -0
  10. package/admin/lib/postbase.js +222 -0
  11. package/admin/package-lock.json +3746 -0
  12. package/admin/package.json +27 -0
  13. package/admin/public/assets/img/admin-ui.png +0 -0
  14. package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
  15. package/admin/public/assets/img/chart-active-users.png +0 -0
  16. package/admin/public/assets/img/icon-transparent.png +0 -0
  17. package/admin/src/App.jsx +48 -0
  18. package/admin/src/auth.js +11 -0
  19. package/admin/src/common/formatDateTime.js +18 -0
  20. package/admin/src/components/AuthPanel.jsx +88 -0
  21. package/admin/src/components/Header.jsx +67 -0
  22. package/admin/src/main.jsx +6 -0
  23. package/admin/src/pages/Dashboard.jsx +24 -0
  24. package/admin/src/pages/Home.jsx +52 -0
  25. package/admin/src/pages/Login.jsx +10 -0
  26. package/admin/src/pages/authentication/Users.jsx +199 -0
  27. package/admin/src/pages/firestore/Database.jsx +29 -0
  28. package/admin/src/pages/storage/files.jsx +29 -0
  29. package/admin/src/postbase.js +15 -0
  30. package/admin/src/styles.css +3 -0
  31. package/admin/tailwind.config.cjs +11 -0
  32. package/admin/template.env +2 -0
  33. package/admin/vite.config.js +21 -0
  34. package/assets/img/HomePageScreenshot.png +0 -0
  35. package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
  36. package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
  37. package/assets/img/expresjs.png +0 -0
  38. package/assets/img/icon-transparent.png +0 -0
  39. package/assets/img/icon.png +0 -0
  40. package/assets/img/letsencrypt-logo-horizontal.png +0 -0
  41. package/assets/img/logo.png +0 -0
  42. package/assets/img/node.js_logo.png +0 -0
  43. package/assets/img/nodejsLight.svg +39 -0
  44. package/assets/img/postgres.png +0 -0
  45. package/backend/README.md +49 -0
  46. package/backend/admin/auth.js +9 -0
  47. package/backend/app.js +68 -0
  48. package/backend/auth.js +92 -0
  49. package/backend/env.js +12 -0
  50. package/backend/lib/postbase/adminClient.js +520 -0
  51. package/backend/lib/postbase/compat/admin.js +44 -0
  52. package/backend/lib/postbase/db.js +17 -0
  53. package/backend/lib/postbase/genericRouter.js +603 -0
  54. package/backend/lib/postbase/local-storage.js +56 -0
  55. package/backend/lib/postbase/metadataCache.js +32 -0
  56. package/backend/lib/postbase/middlewares/auth.js +57 -0
  57. package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
  58. package/backend/lib/postbase/package-lock.json +5873 -0
  59. package/backend/lib/postbase/package.json +19 -0
  60. package/backend/lib/postbase/rtdb/router.js +190 -0
  61. package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
  62. package/backend/lib/postbase/rtdb/ws.js +84 -0
  63. package/backend/lib/postbase/rulesEngine.js +62 -0
  64. package/backend/lib/postbase/storage.js +130 -0
  65. package/backend/lib/postbase/tests/README.md +22 -0
  66. package/backend/lib/postbase/tests/db.js +9 -0
  67. package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
  68. package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
  69. package/backend/lib/postbase/tests/rules.js +26 -0
  70. package/backend/lib/postbase/tests/testServer.js +46 -0
  71. package/backend/lib/postbase/websocket.js +131 -0
  72. package/backend/local.js +6 -0
  73. package/backend/main.js +20 -0
  74. package/backend/middlewares/auth_middleware.js +10 -0
  75. package/backend/migrations/1762137399366-init.sql +98 -0
  76. package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
  77. package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
  78. package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
  79. package/backend/package-lock.json +2374 -0
  80. package/backend/package.json +27 -0
  81. package/backend/postbase_db_rules.js +128 -0
  82. package/backend/postbase_rtdb_rules.js +27 -0
  83. package/backend/postbase_storage_rules.js +45 -0
  84. package/backend/template.env +10 -0
  85. package/backend-systemd/README.md +39 -0
  86. package/backend-systemd/your_website.com.service +12 -0
  87. package/frontend/404.html +33 -0
  88. package/frontend/README.md +25 -0
  89. package/frontend/index.html +15 -0
  90. package/frontend/jsconfig.json +20 -0
  91. package/frontend/lib/postbase/auth.js +132 -0
  92. package/frontend/lib/postbase/compat/firebase/app.js +3 -0
  93. package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
  94. package/frontend/lib/postbase/compat/firebase/database.js +11 -0
  95. package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
  96. package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
  97. package/frontend/lib/postbase/db.js +657 -0
  98. package/frontend/lib/postbase/package-lock.json +6284 -0
  99. package/frontend/lib/postbase/package.json +17 -0
  100. package/frontend/lib/postbase/rtdb.js +108 -0
  101. package/frontend/lib/postbase/storage.js +293 -0
  102. package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
  103. package/frontend/lib/postbase/tests/waitFor.js +13 -0
  104. package/frontend/lib/postbase/utils.js +1 -0
  105. package/frontend/package-lock.json +2977 -0
  106. package/frontend/package.json +24 -0
  107. package/frontend/src/App.jsx +38 -0
  108. package/frontend/src/auth.js +52 -0
  109. package/frontend/src/components/AuthPanel.jsx +85 -0
  110. package/frontend/src/components/Header.jsx +54 -0
  111. package/frontend/src/main.jsx +5 -0
  112. package/frontend/src/pages/Dashboard.jsx +24 -0
  113. package/frontend/src/pages/Home.jsx +178 -0
  114. package/frontend/src/pages/Login.jsx +10 -0
  115. package/frontend/src/postbase.js +14 -0
  116. package/frontend/src/styles.css +1 -0
  117. package/frontend/tailwind.config.cjs +11 -0
  118. package/frontend/template.env +2 -0
  119. package/frontend/vite.config.js +18 -0
  120. package/git/hooks/README.md +31 -0
  121. package/git/hooks/post-receive +26 -0
  122. package/nginx/README.md +84 -0
  123. package/nginx/apt/www.your_website.com.conf +80 -0
  124. package/nginx/homebrew/www.your_website.com.conf +80 -0
  125. package/nginx/letsencrypt/README +14 -0
  126. package/package.json +8 -0
@@ -0,0 +1,603 @@
1
+ import express from 'express';
2
+
3
+ import { runQuery } from './db.js';
4
+ import { MetadataCache } from './metadataCache.js';
5
+ import { makeEvaluator } from './rulesEngine.js';
6
+
7
+ /**
8
+ * Generic JSONB-based router.
9
+ * Each table is assumed to have:
10
+ * id UUID PRIMARY KEY,
11
+ * data JSONB NOT NULL,
12
+ * created_at TIMESTAMPTZ DEFAULT now() at time zone 'UTC',
13
+ * updated_at TIMESTAMPTZ DEFAULT now() at time zone 'UTC'
14
+ *
15
+ * Example: SELECT data FROM users; -- JSON objects
16
+ */
17
+ export function makeGenericRouter({ pool, rulesModule, authField = 'auth' }) {
18
+ const router = express.Router();
19
+ const meta = new MetadataCache(pool);
20
+ const evaluator = makeEvaluator(rulesModule);
21
+
22
+ const ALLOWED_OPS = new Set([
23
+ '==',
24
+ '!=',
25
+ '<',
26
+ '<=',
27
+ '>',
28
+ '>=',
29
+ 'LIKE',
30
+ 'ILIKE',
31
+ 'IN',
32
+ 'ARRAY-CONTAINS', // only supports strings "large" in ["red", "blue", "large"]
33
+ ]);
34
+
35
+ function isDocumentRef(value) {
36
+ if (!value || typeof value !== "object") return false;
37
+ if (value.id && value.path && value.collectionName) return true;
38
+ if (value._type === 'ref' && value.path) return true;
39
+ return false;
40
+ }
41
+
42
+ function resolveReference(ref) {
43
+ if (ref.collectionName && ref.id) return `${ref.collectionName}/${ref.id}`;
44
+ if (ref._type === 'ref' && ref.path) return ref.path;
45
+ }
46
+
47
+ function mapRequest(req) {
48
+ return {
49
+ auth: req[authField] || null,
50
+ method: req.method,
51
+ params: req.params,
52
+ query: req.query,
53
+ body: req.body,
54
+ ip: req.ip,
55
+ };
56
+ }
57
+
58
+ // WHERE builder — works with JSONB fields like data->>'name'
59
+ function buildWhere(filters = []) {
60
+ const whereClauses = [];
61
+ const params = [];
62
+ let idx = 1;
63
+
64
+ for (const f of filters) {
65
+ const { field, op, value } = f;
66
+
67
+ if (!ALLOWED_OPS.has(op.toUpperCase())) throw new Error(`Invalid operator: ${op}`);
68
+
69
+ // array-contains
70
+ if (op.toUpperCase() === 'ARRAY-CONTAINS') {
71
+ let _field = field;
72
+ _field = `data->'${field}'`;
73
+ // Document ID filter
74
+ if (field === "__id") {
75
+ _field = 'id';
76
+ }
77
+ if (value && typeof value === 'object' && value._type === 'ref') {
78
+ params.push(JSON.stringify([value]));
79
+ whereClauses.push(`${_field} @> $${idx++}::jsonb`);
80
+ continue;
81
+ }
82
+ params.push(value);
83
+ whereClauses.push(`(${_field}) ? $${idx++}`);
84
+ }
85
+
86
+ // IN
87
+ else if (op.toUpperCase() === 'IN') {
88
+ let _field = field;
89
+ _field = `data->>'${field}'`;
90
+ // Document ID filter
91
+ if (field === "__id") {
92
+ _field = 'id';
93
+ }
94
+ if (!Array.isArray(value) || value.length === 0)
95
+ throw new Error('IN requires non-empty array');
96
+ const placeholders = value.map(() => `$${idx++}`);
97
+ params.push(...value);
98
+ whereClauses.push(`${_field} IN (${placeholders.join(',')})`);
99
+ }
100
+
101
+ // LIKE / ILIKE
102
+ else if (op.toUpperCase() === 'LIKE' || op.toUpperCase() === 'ILIKE') {
103
+ let _field = field;
104
+ _field = `data->>'${field}'`;
105
+ // Document ID filter
106
+ if (field === "__id") {
107
+ _field = 'id';
108
+ }
109
+ params.push(value);
110
+ whereClauses.push(`${_field} ${op} $${idx++}`);
111
+ }
112
+
113
+ else if (isDocumentRef(value)) {
114
+ let _field = field;
115
+ _field = `data->'${field}'`;
116
+ // Document ID filter
117
+ if (field === "__id") {
118
+ _field = 'id';
119
+ }
120
+ const sqlOp = op === "==" ? "=" : op;
121
+ params.push(resolveReference(value));
122
+ whereClauses.push(`${_field}->>'path' ${sqlOp} $${idx++}`);
123
+ continue;
124
+ }
125
+
126
+ // default (string or primitive)
127
+ else {
128
+ let _field = field;
129
+ _field = `data->>'${field}'`;
130
+ // Document ID filter
131
+ if (field === "__id") {
132
+ _field = 'id';
133
+ }
134
+ const sqlOp = op === "==" ? "=" : op;
135
+ params.push(value);
136
+ whereClauses.push(`${_field} ${sqlOp} $${idx++}`);
137
+ }
138
+ }
139
+
140
+ return {
141
+ whereSql: whereClauses.length ? `WHERE ${whereClauses.join(' AND ')}` : '',
142
+ params
143
+ };
144
+ }
145
+
146
+ // ORDER BY builder — JSON field order
147
+ function buildOrder(order = []) {
148
+ if (!order || !order.length) return '';
149
+ const parts = [];
150
+ for (const o of order) {
151
+ const { field, dir } = o;
152
+ const d = (dir || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
153
+ parts.push(`data->>'${field}' ${d}`);
154
+ }
155
+ return parts.length ? `ORDER BY ${parts.join(', ')}` : '';
156
+ }
157
+
158
+ function applyFieldValues(target, updates) {
159
+ const now = new Date().toISOString();
160
+ const result = { ...target };
161
+
162
+ for (const [k, v] of Object.entries(updates || {})) {
163
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
164
+ result[k] = v;
165
+ continue;
166
+ }
167
+
168
+ // increment
169
+ if (v._op === "increment") {
170
+ const base = Number(result[k] ?? 0);
171
+ result[k] = base + Number(v.by ?? 1);
172
+ continue;
173
+ }
174
+
175
+ // serverTimestamp
176
+ if (v._op === "serverTimestamp") {
177
+ result[k] = now;
178
+ continue;
179
+ }
180
+
181
+ // recurse
182
+ result[k] = applyFieldValues(result[k] ?? {}, v);
183
+ }
184
+
185
+ return result;
186
+ }
187
+
188
+ // === QUERY / LIST ===
189
+ router.post('/:table/query', async (req, res) => {
190
+ const table = req.params.table;
191
+ try {
192
+ const request = mapRequest(req);
193
+ const allowed = await evaluator.evaluate(table, 'read', request, null);
194
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
195
+
196
+ const { filters = [], order = [], limit = 100, offset = 0 } = req.body || {};
197
+
198
+ let { whereSql, params } = buildWhere(filters);
199
+ whereSql = whereSql.replace(/\=\=/g, '=');
200
+ const orderSql = buildOrder(order);
201
+ const limitSql = Number(limit) > 0 ? `LIMIT ${Number(limit)}` : '';
202
+ const offsetSql = Number(offset) > 0 ? `OFFSET ${Number(offset)}` : '';
203
+
204
+ const sql = `
205
+ SELECT id, data, created_at, updated_at
206
+ FROM "${table}"
207
+ ${whereSql} ${orderSql} ${limitSql} ${offsetSql}`;
208
+
209
+ console.log(`Executing sql ${sql}`);
210
+ console.log(`with parmas: ${params}`);
211
+
212
+ const result = await runQuery(pool, sql, params);
213
+
214
+ const out = [];
215
+ for (const r of result.rows || []) {
216
+ const resource = r.data;
217
+ const ok = await evaluator.evaluate(table, 'read', request, resource);
218
+ if (ok) {
219
+ if (resource.hasOwnProperty('id')) {
220
+ resource['key'] = resource['id'];
221
+ delete resource['id'];
222
+ }
223
+ out.push({ id: r.id, ...resource });
224
+ };
225
+ }
226
+ return res.json({ data: out });
227
+ } catch (err) {
228
+ console.error(err);
229
+ res.status(400).json({ error: "Internal Error Occurred" });
230
+ }
231
+ });
232
+
233
+ // === CREATE ===
234
+ router.post('/:table', async (req, res) => {
235
+ const table = req.params.table;
236
+ try {
237
+ const raw = req.body || {};
238
+ const payload = applyFieldValues({}, raw);
239
+ const request = mapRequest(req);
240
+ request.resource = payload;
241
+
242
+ const allowed = await evaluator.evaluate(table, 'create', request, payload);
243
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
244
+
245
+ const sql = `
246
+ INSERT INTO "${table}" (data)
247
+ VALUES ($1)
248
+ RETURNING id, data, created_at, updated_at`;
249
+ const result = await runQuery(pool, sql, [payload]);
250
+ const row = result.rows[0];
251
+ res.status(201).json({ data: { id: row.id, ...row.data } });
252
+ } catch (err) {
253
+ console.error(err);
254
+ res.status(400).json({ error: "Internal Error Occurred" });
255
+ }
256
+ });
257
+
258
+ // === READ ===
259
+ router.get('/:table/:id', async (req, res) => {
260
+ const table = req.params.table;
261
+ const id = req.params.id;
262
+ try {
263
+ const sql = `SELECT id, data, created_at, updated_at FROM "${table}" WHERE id = $1 LIMIT 1`;
264
+ const result = await runQuery(pool, sql, [id]);
265
+ if (!result.rowCount) return res.status(404).json({ error: 'not_found' });
266
+
267
+ const row = result.rows[0];
268
+ const request = mapRequest(req);
269
+ const payload = {
270
+ id,
271
+ ...row.data,
272
+ };
273
+ const allowed = await evaluator.evaluate(table, 'read', request, payload);
274
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
275
+
276
+ res.json({ data: { id: row.id, ...row.data } });
277
+ } catch (err) {
278
+ console.error(err);
279
+ res.status(400).json({ error: "Internal Error Occurred" });
280
+ }
281
+ });
282
+
283
+ // === PUT (replace) ===
284
+ router.put('/:table/:id', async (req, res) => {
285
+ const table = req.params.table;
286
+ const id = req.params.id;
287
+ try {
288
+ const existing = await runQuery(pool, `SELECT data FROM "${table}" WHERE id=$1 LIMIT 1`, [id]);
289
+ const raw = req.body || {};
290
+
291
+ let current = raw;
292
+ if (existing.rowCount) {
293
+ current = existing.rows[0].data;
294
+ }
295
+ current.id = id; // for rules engine
296
+
297
+ const payload = applyFieldValues(current, raw);
298
+
299
+ const request = mapRequest(req);
300
+ request.resource = current;
301
+
302
+ const allowed = await evaluator.evaluate(table, 'update', request, current);
303
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
304
+
305
+ let sql = `
306
+ UPDATE "${table}"
307
+ SET data = $1, updated_at = now() at time zone 'UTC'
308
+ WHERE id = $2
309
+ RETURNING id, data, created_at, updated_at`;
310
+
311
+ if (!existing.rowCount) {
312
+ sql = `
313
+ INSERT INTO "${table}" (data, id, created_at, updated_at)
314
+ VALUES ($1, $2, now() at time zone 'UTC', now() at time zone 'UTC')
315
+ RETURNING id, data, created_at, updated_at`;
316
+ }
317
+
318
+ const result = await runQuery(pool, sql, [payload, id]);
319
+ const row = result.rows[0];
320
+ res.json({ data: { id: row.id, ...row.data } });
321
+ } catch (err) {
322
+ console.error(err);
323
+ res.status(400).json({ error: "Internal Error Occurred" });
324
+ }
325
+ });
326
+
327
+ // === PATCH (partial merge) ===
328
+ router.patch('/:table/:id', async (req, res) => {
329
+ const table = req.params.table;
330
+ const id = req.params.id;
331
+ try {
332
+ const existing = await runQuery(pool, `SELECT data FROM "${table}" WHERE id=$1 LIMIT 1`, [id]);
333
+ if (!existing.rowCount) return res.status(404).json({ error: 'not_found' });
334
+ const current = existing.rows[0].data;
335
+ current.id = id; // for rules engine
336
+
337
+ const request = mapRequest(req);
338
+ request.resource = current;
339
+ const allowed = await evaluator.evaluate(table, 'update', request, current);
340
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
341
+
342
+ const payload = req.body || {};
343
+ const merged = applyFieldValues(current, payload);
344
+ const sql = `
345
+ UPDATE "${table}"
346
+ SET data = $1, updated_at = now() at time zone 'UTC'
347
+ WHERE id = $2
348
+ RETURNING id, data, created_at, updated_at`;
349
+ const result = await runQuery(pool, sql, [merged, id]);
350
+ const row = result.rows[0];
351
+ res.json({ data: { id: row.id, ...row.data } });
352
+ } catch (err) {
353
+ console.error(err);
354
+ res.status(400).json({ error: "Internal Error Occurred" });
355
+ }
356
+ });
357
+
358
+ // === DELETE ===
359
+ router.delete('/:table/:id', async (req, res) => {
360
+ const table = req.params.table;
361
+ const id = req.params.id;
362
+ try {
363
+ const existing = await runQuery(pool, `SELECT data FROM "${table}" WHERE id=$1 LIMIT 1`, [id]);
364
+ if (!existing.rowCount) return res.status(404).json({ error: 'not_found' });
365
+
366
+ const current = existing.rows[0].data;
367
+ current.id = id; // for rules engine
368
+ const request = mapRequest(req);
369
+ request.resource = current;
370
+ const allowed = await evaluator.evaluate(table, 'delete', request, current);
371
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
372
+
373
+ const sql = `DELETE FROM "${table}" WHERE id=$1 RETURNING id`;
374
+ const result = await runQuery(pool, sql, [id]);
375
+ res.json({ data: { id: result.rows[0].id } });
376
+ } catch (err) {
377
+ console.error(err);
378
+ res.status(400).json({ error: "Internal Error Occurred" });
379
+ }
380
+ });
381
+
382
+ // ======================================================
383
+ // =============== SUBCOLLECTION SUPPORT =================
384
+ // ======================================================
385
+ //
386
+ // Pattern supported:
387
+ //
388
+ // /:parentTable/:parentId/:subTable (query / create)
389
+ // /:parentTable/:parentId/:subTable/:id (read / update / delete)
390
+ //
391
+ // Assumes subcollection rows contain:
392
+ //
393
+ // data.parent = {
394
+ // collectionName: "<parentTable>",
395
+ // id: "<parentId>"
396
+ // }
397
+ //
398
+ // You can rename this field if needed.
399
+ // ======================================================
400
+
401
+ // --- LIST / QUERY subcollection ---
402
+ router.post('/:parentTable/:parentId/:subTable/query', async (req, res) => {
403
+ const { parentTable, parentId, subTable } = req.params;
404
+ try {
405
+ const request = mapRequest(req);
406
+
407
+ // Evaluate access for subcollection as if it were its own table
408
+ const allowed = await evaluator.evaluate(subTable, 'read', request, null);
409
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
410
+
411
+ const { filters = [], order = [], limit = 100, offset = 0 } = req.body || {};
412
+
413
+ // Always force parent filter
414
+ filters.push({
415
+ field: 'parent',
416
+ op: '==',
417
+ value: {
418
+ collectionName: parentTable,
419
+ id: parentId,
420
+ path: `${parentTable}/${parentId}`,
421
+ }
422
+ });
423
+
424
+ let { whereSql, params } = buildWhere(filters);
425
+ whereSql = whereSql.replace(/\=\=/g, '=');
426
+ const orderSql = buildOrder(order);
427
+ const limitSql = Number(limit) > 0 ? `LIMIT ${Number(limit)}` : '';
428
+ const offsetSql = Number(offset) > 0 ? `OFFSET ${Number(offset)}` : '';
429
+
430
+ const sql = `
431
+ SELECT id, data, created_at, updated_at
432
+ FROM "${subTable}"
433
+ ${whereSql} ${orderSql} ${limitSql} ${offsetSql}`;
434
+
435
+ const result = await runQuery(pool, sql, params);
436
+
437
+ const out = [];
438
+ for (const r of result.rows || []) {
439
+ const resource = r.data;
440
+ const ok = await evaluator.evaluate(subTable, 'read', request, resource);
441
+ if (ok) out.push({ id: r.id, ...resource });
442
+ }
443
+
444
+ return res.json({ data: out });
445
+ } catch (err) {
446
+ console.error(err);
447
+ res.status(400).json({ error: "Internal Error Occurred" });
448
+ }
449
+ });
450
+
451
+ // --- CREATE subcollection ---
452
+ router.post('/:parentTable/:parentId/:subTable', async (req, res) => {
453
+ const { parentTable, parentId, subTable } = req.params;
454
+ try {
455
+ const raw = req.body || {};
456
+
457
+ // enforce parent link with path support (fix for same-name collections)
458
+ raw.parent = {
459
+ collectionName: parentTable,
460
+ id: parentId,
461
+ path: `${parentTable}/${parentId}`
462
+ };
463
+
464
+ const payload = applyFieldValues({}, raw);
465
+
466
+ const request = mapRequest(req);
467
+ request.resource = payload;
468
+
469
+ const allowed = await evaluator.evaluate(subTable, 'create', request, payload);
470
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
471
+
472
+ const sql = `
473
+ INSERT INTO "${subTable}" (data)
474
+ VALUES ($1)
475
+ RETURNING id, data, created_at, updated_at`;
476
+
477
+ const result = await runQuery(pool, sql, [payload]);
478
+ const row = result.rows[0];
479
+
480
+ res.status(201).json({ data: { id: row.id, ...row.data } });
481
+ } catch (err) {
482
+ console.error(err);
483
+ res.status(400).json({ error: "Internal Error Occurred" });
484
+ }
485
+ });
486
+
487
+ // --- READ subcollection item ---
488
+ router.get('/:parentTable/:parentId/:subTable/:id', async (req, res) => {
489
+ const { parentTable, parentId, subTable, id } = req.params;
490
+ try {
491
+ const sql = `
492
+ SELECT id, data
493
+ FROM "${subTable}"
494
+ WHERE id = $1 LIMIT 1`;
495
+ const result = await runQuery(pool, sql, [id]);
496
+ if (!result.rowCount) return res.status(404).json({ error: 'not_found' });
497
+
498
+ const row = result.rows[0];
499
+
500
+ // validate belongs to parent
501
+ if (!row.data.parent ||
502
+ row.data.parent.path !== `${parentTable}/${parentId}` ||
503
+ row.data.parent.id !== parentId) {
504
+ return res.status(404).json({ error: 'not_found' });
505
+ }
506
+
507
+ const request = mapRequest(req);
508
+ const resource = { id, ...row.data };
509
+
510
+ const allowed = await evaluator.evaluate(subTable, 'read', request, resource);
511
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
512
+
513
+ res.json({ data: { id, ...row.data } });
514
+ } catch (err) {
515
+ console.error(err);
516
+ res.status(400).json({ error: "Internal Error Occurred" });
517
+ }
518
+ });
519
+
520
+ // --- UPDATE subcollection ---
521
+ router.put('/:parentTable/:parentId/:subTable/:id', async (req, res) => {
522
+ const { parentTable, parentId, subTable, id } = req.params;
523
+ try {
524
+ const existing = await runQuery(
525
+ pool,
526
+ `SELECT data FROM "${subTable}" WHERE id=$1 LIMIT 1`,
527
+ [id]
528
+ );
529
+ if (!existing.rowCount) return res.status(404).json({ error: 'not_found' });
530
+
531
+ const current = existing.rows[0].data;
532
+
533
+ // enforce parent link
534
+ if (!current.parent ||
535
+ current.parent.path !== `${parentTable}/${parentId}` ||
536
+ current.parent.id !== parentId)
537
+ return res.status(404).json({ error: 'not_found' });
538
+
539
+ current.id = id;
540
+
541
+ const request = mapRequest(req);
542
+ request.resource = current;
543
+
544
+ const allowed = await evaluator.evaluate(subTable, 'update', request, current);
545
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
546
+
547
+ const raw = req.body || {};
548
+ const payload = applyFieldValues(current, raw);
549
+ payload.parent = current.parent; // prevent overriding parent
550
+
551
+ const sql = `
552
+ UPDATE "${subTable}"
553
+ SET data = $1, updated_at = now() at time zone 'UTC'
554
+ WHERE id = $2
555
+ RETURNING id, data`;
556
+
557
+ const result = await runQuery(pool, sql, [payload, id]);
558
+ const row = result.rows[0];
559
+
560
+ res.json({ data: { id, ...row.data } });
561
+ } catch (err) {
562
+ console.error(err);
563
+ res.status(400).json({ error: "Internal Error Occurred" });
564
+ }
565
+ });
566
+
567
+ // --- DELETE subcollection ---
568
+ router.delete('/:parentTable/:parentId/:subTable/:id', async (req, res) => {
569
+ const { parentTable, parentId, subTable, id } = req.params;
570
+ try {
571
+ const existing = await runQuery(
572
+ pool,
573
+ `SELECT data FROM "${subTable}" WHERE id=$1 LIMIT 1`,
574
+ [id]
575
+ );
576
+ if (!existing.rowCount) return res.status(404).json({ error: 'not_found' });
577
+
578
+ const current = existing.rows[0].data;
579
+
580
+ if (!current.parent ||
581
+ current.parent.path !== `${parentTable}/${parentId}` ||
582
+ current.parent.id !== parentId)
583
+ return res.status(404).json({ error: 'not_found' });
584
+
585
+ current.id = id;
586
+
587
+ const request = mapRequest(req);
588
+ request.resource = current;
589
+
590
+ const allowed = await evaluator.evaluate(subTable, 'delete', request, current);
591
+ if (!allowed) return res.status(403).json({ error: 'forbidden' });
592
+
593
+ const result = await runQuery(pool, `DELETE FROM "${subTable}" WHERE id=$1 RETURNING id`, [id]);
594
+ res.json({ data: { id: result.rows[0].id } });
595
+ } catch (err) {
596
+ console.error(err);
597
+ res.status(400).json({ error: "Internal Error Occurred" });
598
+ }
599
+ });
600
+
601
+
602
+ return router;
603
+ }
@@ -0,0 +1,56 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // Helper class representing a file in storage
6
+ class LocalFile {
7
+ constructor(bucketPath, publicBaseUrl, filePath) {
8
+ this.bucketPath = bucketPath;
9
+ this.publicBaseUrl = publicBaseUrl;
10
+ this.filePath = filePath;
11
+ this.fullPath = path.join(bucketPath, filePath);
12
+ }
13
+
14
+ async save(buffer, options = {}) {
15
+ // Ensure directory exists
16
+ await fs.promises.mkdir(path.dirname(this.fullPath), { recursive: true });
17
+
18
+ // Write the file
19
+ await fs.promises.writeFile(this.fullPath, buffer);
20
+
21
+ if (options.contentType) {
22
+ // Optionally, save metadata if needed in future (ignored here)
23
+ }
24
+ }
25
+
26
+ async makePublic() {
27
+ // On a local file system served by Nginx, files are public by default.
28
+ // This function exists for API compatibility only.
29
+ return true;
30
+ }
31
+
32
+ publicUrl() {
33
+ // Create a URL like: https://www.app.com/uploads/path/to/file.png
34
+ const relativePath = path.relative(this.bucketPath, this.fullPath);
35
+ return `${this.publicBaseUrl.replace(/\/+$/, '')}/${relativePath}`;
36
+ }
37
+ }
38
+
39
+ // Main Storage Bucket class
40
+ class LocalBucket {
41
+ constructor(bucketPath, publicBaseUrl) {
42
+ this.bucketPath = bucketPath;
43
+ this.publicBaseUrl = publicBaseUrl;
44
+ }
45
+
46
+ file(filePath) {
47
+ return new LocalFile(this.bucketPath, this.publicBaseUrl, filePath);
48
+ }
49
+ }
50
+
51
+ // Storage interface (mimics `admin.storage().bucket()`)
52
+ export function createLocalStorage(bucketPath, publicBaseUrl) {
53
+ return {
54
+ bucket: () => new LocalBucket(bucketPath, publicBaseUrl)
55
+ };
56
+ }
@@ -0,0 +1,32 @@
1
+ // Simple cache for table columns to validate fields and to build safe queries.
2
+
3
+ export class MetadataCache {
4
+ constructor(pool) {
5
+ this.pool = pool;
6
+ this.cache = new Map(); // tableName -> { columns: Set([...]), pk: 'id' }
7
+ }
8
+
9
+ async loadTable(tableName) {
10
+ if (this.cache.has(tableName)) return this.cache.get(tableName);
11
+ const res = await this.pool.query(
12
+ `SELECT column_name, ordinal_position
13
+ FROM information_schema.columns
14
+ WHERE table_schema = 'public' AND table_name = $1
15
+ ORDER BY ordinal_position`,
16
+ [tableName]
17
+ );
18
+ if (!res.rowCount) {
19
+ throw new Error(`Table "${tableName}" not found`);
20
+ }
21
+ const cols = new Set(res.rows.map(r => r.column_name));
22
+ // default PK is 'id' if present; otherwise first column
23
+ const pk = cols.has('id') ? 'id' : res.rows[0].column_name;
24
+ const meta = { columns: cols, pk };
25
+ this.cache.set(tableName, meta);
26
+ return meta;
27
+ }
28
+
29
+ invalidate(tableName) {
30
+ this.cache.delete(tableName);
31
+ }
32
+ }