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.
- package/.github/workflows/test.yml +74 -0
- package/CLA.md +60 -0
- package/CONTRIBUTORS.md +35 -0
- package/LICENSE +661 -0
- package/README.md +211 -0
- package/admin/404.html +33 -0
- package/admin/README.md +21 -0
- package/admin/index.html +15 -0
- package/admin/jsconfig.json +20 -0
- package/admin/lib/postbase.js +222 -0
- package/admin/package-lock.json +3746 -0
- package/admin/package.json +27 -0
- package/admin/public/assets/img/admin-ui.png +0 -0
- package/admin/public/assets/img/blank-profile-picture-960_720.webp +0 -0
- package/admin/public/assets/img/chart-active-users.png +0 -0
- package/admin/public/assets/img/icon-transparent.png +0 -0
- package/admin/src/App.jsx +48 -0
- package/admin/src/auth.js +11 -0
- package/admin/src/common/formatDateTime.js +18 -0
- package/admin/src/components/AuthPanel.jsx +88 -0
- package/admin/src/components/Header.jsx +67 -0
- package/admin/src/main.jsx +6 -0
- package/admin/src/pages/Dashboard.jsx +24 -0
- package/admin/src/pages/Home.jsx +52 -0
- package/admin/src/pages/Login.jsx +10 -0
- package/admin/src/pages/authentication/Users.jsx +199 -0
- package/admin/src/pages/firestore/Database.jsx +29 -0
- package/admin/src/pages/storage/files.jsx +29 -0
- package/admin/src/postbase.js +15 -0
- package/admin/src/styles.css +3 -0
- package/admin/tailwind.config.cjs +11 -0
- package/admin/template.env +2 -0
- package/admin/vite.config.js +21 -0
- package/assets/img/HomePageScreenshot.png +0 -0
- package/assets/img/better-auth-logo-dark.136b122f.png +0 -0
- package/assets/img/better-auth-logo-light.4b03f444.png +0 -0
- package/assets/img/expresjs.png +0 -0
- package/assets/img/icon-transparent.png +0 -0
- package/assets/img/icon.png +0 -0
- package/assets/img/letsencrypt-logo-horizontal.png +0 -0
- package/assets/img/logo.png +0 -0
- package/assets/img/node.js_logo.png +0 -0
- package/assets/img/nodejsLight.svg +39 -0
- package/assets/img/postgres.png +0 -0
- package/backend/README.md +49 -0
- package/backend/admin/auth.js +9 -0
- package/backend/app.js +68 -0
- package/backend/auth.js +92 -0
- package/backend/env.js +12 -0
- package/backend/lib/postbase/adminClient.js +520 -0
- package/backend/lib/postbase/compat/admin.js +44 -0
- package/backend/lib/postbase/db.js +17 -0
- package/backend/lib/postbase/genericRouter.js +603 -0
- package/backend/lib/postbase/local-storage.js +56 -0
- package/backend/lib/postbase/metadataCache.js +32 -0
- package/backend/lib/postbase/middlewares/auth.js +57 -0
- package/backend/lib/postbase/migrations/1765239687559_rtdb-nodes.js +93 -0
- package/backend/lib/postbase/package-lock.json +5873 -0
- package/backend/lib/postbase/package.json +19 -0
- package/backend/lib/postbase/rtdb/router.js +190 -0
- package/backend/lib/postbase/rtdb/rulesEngine.js +63 -0
- package/backend/lib/postbase/rtdb/ws.js +84 -0
- package/backend/lib/postbase/rulesEngine.js +62 -0
- package/backend/lib/postbase/storage.js +130 -0
- package/backend/lib/postbase/tests/README.md +22 -0
- package/backend/lib/postbase/tests/db.js +9 -0
- package/backend/lib/postbase/tests/rtdb.rest.test.js +46 -0
- package/backend/lib/postbase/tests/rtdb.ws.test.js +113 -0
- package/backend/lib/postbase/tests/rules.js +26 -0
- package/backend/lib/postbase/tests/testServer.js +46 -0
- package/backend/lib/postbase/websocket.js +131 -0
- package/backend/local.js +6 -0
- package/backend/main.js +20 -0
- package/backend/middlewares/auth_middleware.js +10 -0
- package/backend/migrations/1762137399366-init.sql +98 -0
- package/backend/migrations/1762137399367_init_jsonb_schema.js +68 -0
- package/backend/migrations/1762149999999_enable_realtime_changes.js +48 -0
- package/backend/migrations/1765224247654_rtdb-nodes.js +93 -0
- package/backend/package-lock.json +2374 -0
- package/backend/package.json +27 -0
- package/backend/postbase_db_rules.js +128 -0
- package/backend/postbase_rtdb_rules.js +27 -0
- package/backend/postbase_storage_rules.js +45 -0
- package/backend/template.env +10 -0
- package/backend-systemd/README.md +39 -0
- package/backend-systemd/your_website.com.service +12 -0
- package/frontend/404.html +33 -0
- package/frontend/README.md +25 -0
- package/frontend/index.html +15 -0
- package/frontend/jsconfig.json +20 -0
- package/frontend/lib/postbase/auth.js +132 -0
- package/frontend/lib/postbase/compat/firebase/app.js +3 -0
- package/frontend/lib/postbase/compat/firebase/auth.js +115 -0
- package/frontend/lib/postbase/compat/firebase/database.js +11 -0
- package/frontend/lib/postbase/compat/firebase/firestore/lite.js +61 -0
- package/frontend/lib/postbase/compat/firebase/storage.js +10 -0
- package/frontend/lib/postbase/db.js +657 -0
- package/frontend/lib/postbase/package-lock.json +6284 -0
- package/frontend/lib/postbase/package.json +17 -0
- package/frontend/lib/postbase/rtdb.js +108 -0
- package/frontend/lib/postbase/storage.js +293 -0
- package/frontend/lib/postbase/tests/rtdb.client.test.js +88 -0
- package/frontend/lib/postbase/tests/waitFor.js +13 -0
- package/frontend/lib/postbase/utils.js +1 -0
- package/frontend/package-lock.json +2977 -0
- package/frontend/package.json +24 -0
- package/frontend/src/App.jsx +38 -0
- package/frontend/src/auth.js +52 -0
- package/frontend/src/components/AuthPanel.jsx +85 -0
- package/frontend/src/components/Header.jsx +54 -0
- package/frontend/src/main.jsx +5 -0
- package/frontend/src/pages/Dashboard.jsx +24 -0
- package/frontend/src/pages/Home.jsx +178 -0
- package/frontend/src/pages/Login.jsx +10 -0
- package/frontend/src/postbase.js +14 -0
- package/frontend/src/styles.css +1 -0
- package/frontend/tailwind.config.cjs +11 -0
- package/frontend/template.env +2 -0
- package/frontend/vite.config.js +18 -0
- package/git/hooks/README.md +31 -0
- package/git/hooks/post-receive +26 -0
- package/nginx/README.md +84 -0
- package/nginx/apt/www.your_website.com.conf +80 -0
- package/nginx/homebrew/www.your_website.com.conf +80 -0
- package/nginx/letsencrypt/README +14 -0
- 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
|
+
}
|