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,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@postbase/backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"migrate:up": "node-pg-migrate up",
|
|
7
|
+
"migrate:down": "node-pg-migrate down",
|
|
8
|
+
"migrate:redo": "node-pg-migrate redo",
|
|
9
|
+
"migrate:create": "node-pg-migrate create",
|
|
10
|
+
"test": "jest"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"express": "^4.21.2",
|
|
14
|
+
"jest": "^30.2.0",
|
|
15
|
+
"node-pg-migrate": "^8.0.3",
|
|
16
|
+
"supertest": "^7.1.4",
|
|
17
|
+
"ws": "^8.20.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
import { makeRtdbEvaluator } from './rulesEngine.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* rtdb_nodes schema assumptions:
|
|
7
|
+
* - path TEXT PRIMARY KEY
|
|
8
|
+
* - parent_path TEXT
|
|
9
|
+
* - key TEXT
|
|
10
|
+
* - value JSONB
|
|
11
|
+
* - version BIGINT
|
|
12
|
+
* - updated_at TIMESTAMPTZ
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function createRtdbRouter({ pool, notify, rulesModule }) {
|
|
16
|
+
const rules = makeRtdbEvaluator(rulesModule);
|
|
17
|
+
|
|
18
|
+
const router = express.Router();
|
|
19
|
+
|
|
20
|
+
const clean = p => (p || '').replace(/^\/+|\/+$/g, '');
|
|
21
|
+
|
|
22
|
+
const splitPath = path => {
|
|
23
|
+
const parts = path.split('/');
|
|
24
|
+
return {
|
|
25
|
+
key: parts.at(-1),
|
|
26
|
+
parent: parts.length > 1 ? parts.slice(0, -1).join('/') : null,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// =====================================================
|
|
31
|
+
// GET node
|
|
32
|
+
// =====================================================
|
|
33
|
+
router.get('/*', async (req, res) => {
|
|
34
|
+
const path = clean(req.params[0]);
|
|
35
|
+
|
|
36
|
+
const r = await pool.query(
|
|
37
|
+
`SELECT value FROM rtdb_nodes WHERE path = $1`,
|
|
38
|
+
[path]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!r.rowCount) return res.sendStatus(404);
|
|
42
|
+
|
|
43
|
+
const allowed = await rules.evaluate("read", {
|
|
44
|
+
auth: req.auth || null,
|
|
45
|
+
method: "GET"
|
|
46
|
+
}, {
|
|
47
|
+
path,
|
|
48
|
+
value: r
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!allowed) return res.sendStatus(403);
|
|
52
|
+
|
|
53
|
+
res.json(r.rows[0].value);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// =====================================================
|
|
57
|
+
// SET (replace node)
|
|
58
|
+
// =====================================================
|
|
59
|
+
router.put('/*', async (req, res) => {
|
|
60
|
+
const path = clean(req.params[0]);
|
|
61
|
+
const value = req.body ?? {};
|
|
62
|
+
|
|
63
|
+
const allowed = await rules.evaluate("write", {
|
|
64
|
+
auth: req.auth || null,
|
|
65
|
+
method: "PUT",
|
|
66
|
+
body: value
|
|
67
|
+
}, {
|
|
68
|
+
path,
|
|
69
|
+
newValue: value
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!allowed) return res.sendStatus(403);
|
|
73
|
+
|
|
74
|
+
const { key, parent } = splitPath(path);
|
|
75
|
+
|
|
76
|
+
const q = `
|
|
77
|
+
INSERT INTO rtdb_nodes (path, parent_path, key, value)
|
|
78
|
+
VALUES ($1, $2, $3, $4)
|
|
79
|
+
ON CONFLICT (path)
|
|
80
|
+
DO UPDATE SET
|
|
81
|
+
value = EXCLUDED.value,
|
|
82
|
+
version = rtdb_nodes.version + 1,
|
|
83
|
+
updated_at = now() at time zone 'UTC'
|
|
84
|
+
RETURNING value
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const r = await pool.query(q, [path, parent, key, value]);
|
|
88
|
+
|
|
89
|
+
await notify(path, value);
|
|
90
|
+
res.json(r.rows[0].value);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// =====================================================
|
|
94
|
+
// UPDATE (merge)
|
|
95
|
+
// =====================================================
|
|
96
|
+
router.patch('/*', async (req, res) => {
|
|
97
|
+
const path = clean(req.params[0]);
|
|
98
|
+
const patch = req.body ?? {};
|
|
99
|
+
|
|
100
|
+
const allowed = await rules.evaluate("write", {
|
|
101
|
+
auth: req.auth || null,
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
body: patch
|
|
104
|
+
}, {
|
|
105
|
+
path,
|
|
106
|
+
newValue: patch
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!allowed) return res.sendStatus(403);
|
|
110
|
+
|
|
111
|
+
const cur = await pool.query(
|
|
112
|
+
`SELECT value FROM rtdb_nodes WHERE path = $1`,
|
|
113
|
+
[path]
|
|
114
|
+
);
|
|
115
|
+
if (!cur.rowCount) return res.sendStatus(404);
|
|
116
|
+
|
|
117
|
+
const oldValue = cur.rows[0].value;
|
|
118
|
+
const newValue = { ...oldValue, ...patch };
|
|
119
|
+
|
|
120
|
+
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) {
|
|
121
|
+
return res.status(204).end();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await pool.query(
|
|
125
|
+
`
|
|
126
|
+
UPDATE rtdb_nodes
|
|
127
|
+
SET value = $1,
|
|
128
|
+
version = version + 1,
|
|
129
|
+
updated_at = now() at time zone 'UTC'
|
|
130
|
+
WHERE path = $2
|
|
131
|
+
`,
|
|
132
|
+
[newValue, path]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await notify(path, newValue, oldValue);
|
|
136
|
+
res.json(newValue);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// =====================================================
|
|
140
|
+
// DELETE node + subtree
|
|
141
|
+
// =====================================================
|
|
142
|
+
router.delete('/*', async (req, res) => {
|
|
143
|
+
const path = clean(req.params[0]);
|
|
144
|
+
|
|
145
|
+
const allowed = await rules.evaluate("delete", {
|
|
146
|
+
auth: req.auth || null,
|
|
147
|
+
method: "DELETE",
|
|
148
|
+
body: patch
|
|
149
|
+
}, {
|
|
150
|
+
path,
|
|
151
|
+
newValue: req.body ?? {},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!allowed) return res.sendStatus(403);
|
|
155
|
+
|
|
156
|
+
await pool.query(
|
|
157
|
+
`
|
|
158
|
+
DELETE FROM rtdb_nodes
|
|
159
|
+
WHERE path = $1 OR parent_path LIKE $2
|
|
160
|
+
`,
|
|
161
|
+
[path, `${path}/%`]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await notify(path, null);
|
|
165
|
+
res.json({ ok: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// =====================================================
|
|
169
|
+
// PUSH (generate child key)
|
|
170
|
+
// =====================================================
|
|
171
|
+
router.post('/*/push', async (req, res) => {
|
|
172
|
+
const parent = clean(req.params[0]);
|
|
173
|
+
const key = Math.random().toString(36).slice(2, 10);
|
|
174
|
+
const path = `${parent}/${key}`;
|
|
175
|
+
const value = req.body ?? {};
|
|
176
|
+
|
|
177
|
+
await pool.query(
|
|
178
|
+
`
|
|
179
|
+
INSERT INTO rtdb_nodes (path, parent_path, key, value)
|
|
180
|
+
VALUES ($1, $2, $3, $4)
|
|
181
|
+
`,
|
|
182
|
+
[path, parent, key, value]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await notify(path, value);
|
|
186
|
+
res.status(201).json({ key, path });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return router;
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// backend/lib/postbase/rtdb/rulesEngine.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract /users/$uid → { uid: "u1" }
|
|
5
|
+
*/
|
|
6
|
+
function matchPathRule(rulePath, actualPath) {
|
|
7
|
+
const rSeg = rulePath.split("/").filter(Boolean);
|
|
8
|
+
const aSeg = actualPath.split("/").filter(Boolean);
|
|
9
|
+
|
|
10
|
+
if (rSeg.length !== aSeg.length) return null;
|
|
11
|
+
|
|
12
|
+
const params = {};
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < rSeg.length; i++) {
|
|
15
|
+
const r = rSeg[i];
|
|
16
|
+
const a = aSeg[i];
|
|
17
|
+
|
|
18
|
+
if (r.startsWith("$")) {
|
|
19
|
+
params[r.slice(1)] = a;
|
|
20
|
+
} else if (r !== a) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return params;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeRtdbEvaluator(rulesModule) {
|
|
29
|
+
const rulePaths = Object.entries(rulesModule.paths || {});
|
|
30
|
+
const defaults = rulesModule.default || { read: false, write: false };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* op = "read" or "write"
|
|
34
|
+
*/
|
|
35
|
+
async function evaluate(op, request, context) {
|
|
36
|
+
const { path } = context;
|
|
37
|
+
|
|
38
|
+
// Find matching rule
|
|
39
|
+
for (const [rulePath, ruleDef] of rulePaths) {
|
|
40
|
+
const params = matchPathRule(rulePath, path);
|
|
41
|
+
if (params) {
|
|
42
|
+
context.params = params;
|
|
43
|
+
const fn = ruleDef[op];
|
|
44
|
+
|
|
45
|
+
if (typeof fn === "function") {
|
|
46
|
+
const r = fn(request, context);
|
|
47
|
+
return r instanceof Promise ? await r : !!r;
|
|
48
|
+
}
|
|
49
|
+
if (typeof fn === "boolean") return fn;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Apply defaults
|
|
54
|
+
const def = defaults[op];
|
|
55
|
+
if (typeof def === "function") {
|
|
56
|
+
const r = def(request, context);
|
|
57
|
+
return r instanceof Promise ? await r : !!r;
|
|
58
|
+
}
|
|
59
|
+
return !!def;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { evaluate };
|
|
63
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function createRtdbWs(wss) {
|
|
2
|
+
const exact = new Map(); // path → Set<ws>
|
|
3
|
+
const prefixes = new Map(); // prefix → Set<ws>
|
|
4
|
+
const fields = new Map(); // path|field → Set<ws>
|
|
5
|
+
|
|
6
|
+
const clean = p => (p || '').replace(/^\/+|\/+$/g, '');
|
|
7
|
+
|
|
8
|
+
const add = (map, key, ws) => {
|
|
9
|
+
if (!map.has(key)) map.set(key, new Set());
|
|
10
|
+
map.get(key).add(ws);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const removeAll = ws => {
|
|
14
|
+
for (const map of [exact, prefixes, fields]) {
|
|
15
|
+
for (const [k, set] of map) {
|
|
16
|
+
set.delete(ws);
|
|
17
|
+
if (!set.size) map.delete(k);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
wss.on('connection', ws => {
|
|
23
|
+
ws.on('message', msg => {
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(msg.toString());
|
|
26
|
+
const path = clean(data.path);
|
|
27
|
+
|
|
28
|
+
if (data.type === 'sub') {
|
|
29
|
+
if (data.field)
|
|
30
|
+
add(fields, `${path}|${data.field}`, ws);
|
|
31
|
+
else if (data.prefix)
|
|
32
|
+
add(prefixes, `${path}/`, ws);
|
|
33
|
+
else
|
|
34
|
+
add(exact, path, ws);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (data.type === 'unsub') removeAll(ws);
|
|
38
|
+
} catch {}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
ws.on('close', () => removeAll(ws));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// -----------------------------------------------------
|
|
45
|
+
// Notifier (called by REST)
|
|
46
|
+
// -----------------------------------------------------
|
|
47
|
+
async function notify(path, newVal, oldVal = null) {
|
|
48
|
+
const p = clean(path);
|
|
49
|
+
|
|
50
|
+
// exact listeners
|
|
51
|
+
if (exact.has(p)) {
|
|
52
|
+
const msg = JSON.stringify({ path: p, value: newVal });
|
|
53
|
+
exact.get(p).forEach(ws => ws.send(msg));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// prefix listeners
|
|
57
|
+
for (const [pre, set] of prefixes) {
|
|
58
|
+
if (p.startsWith(pre)) {
|
|
59
|
+
const msg = JSON.stringify({ path: p, value: newVal });
|
|
60
|
+
set.forEach(ws => ws.send(msg));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// field listeners
|
|
65
|
+
if (newVal) {
|
|
66
|
+
for (const [k, set] of fields) {
|
|
67
|
+
const [fp, field] = k.split('|');
|
|
68
|
+
if (fp !== p) continue;
|
|
69
|
+
|
|
70
|
+
const oldFieldValue = oldVal ? oldVal[field] : undefined;
|
|
71
|
+
if (newVal[field] !== oldFieldValue) {
|
|
72
|
+
const msg = JSON.stringify({
|
|
73
|
+
path: p,
|
|
74
|
+
field,
|
|
75
|
+
value: newVal[field],
|
|
76
|
+
});
|
|
77
|
+
set.forEach(ws => ws.send(msg));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { notify };
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple rules engine. Rules are plain javascript functions that receive:
|
|
3
|
+
* - request: {auth, method, params, query, body, ip}
|
|
4
|
+
* - resource: for read/update/delete: current row (object); for create: incoming data
|
|
5
|
+
*
|
|
6
|
+
* Rules file should export default object:
|
|
7
|
+
* {
|
|
8
|
+
* tables: {
|
|
9
|
+
* posts: {
|
|
10
|
+
* read: (request, resource) => boolean | Promise<boolean>,
|
|
11
|
+
* create: (request, resource) => boolean | Promise<boolean>,
|
|
12
|
+
* update: ...,
|
|
13
|
+
* delete: ...
|
|
14
|
+
* }
|
|
15
|
+
* },
|
|
16
|
+
* default: { read: true, create: false, update: false, delete: false }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export function makeEvaluator(rulesModule = {}) {
|
|
21
|
+
const tables = (rulesModule && rulesModule.tables) || {};
|
|
22
|
+
const defaults = (rulesModule && rulesModule.default) || { read: true, create: true, update: true, delete: true };
|
|
23
|
+
|
|
24
|
+
async function evaluate(tableName, op, request, resource = null) {
|
|
25
|
+
const tableRules = tables[tableName];
|
|
26
|
+
let fn;
|
|
27
|
+
if (tableRules && typeof tableRules[op] === 'function') {
|
|
28
|
+
fn = tableRules[op];
|
|
29
|
+
} else if (defaults && typeof defaults[op] === 'function') {
|
|
30
|
+
fn = defaults[op];
|
|
31
|
+
} else {
|
|
32
|
+
// boolean default allowed/denied
|
|
33
|
+
const val = (defaults && defaults[op]);
|
|
34
|
+
if (typeof val === 'boolean') return val;
|
|
35
|
+
// fallback allow
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const result = fn(request, resource);
|
|
39
|
+
if (result && typeof result.then === 'function') {
|
|
40
|
+
return await result;
|
|
41
|
+
}
|
|
42
|
+
return !!result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { evaluate };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Helper functions you can import into your rules file to keep rules concise.
|
|
50
|
+
*/
|
|
51
|
+
export const RuleHelpers = {
|
|
52
|
+
isAuth: (request) => !!request.auth,
|
|
53
|
+
uidEquals: (request, propOrValue) => {
|
|
54
|
+
if (!request.auth) return false;
|
|
55
|
+
// propOrValue can be a function(resource) or a string path
|
|
56
|
+
if (typeof propOrValue === 'function') return request.auth.id === propOrValue(request.resource);
|
|
57
|
+
return request.auth.id === propOrValue;
|
|
58
|
+
},
|
|
59
|
+
allowIf: (pred) => (request, resource) => !!pred(request, resource),
|
|
60
|
+
and: (...preds) => (req, res) => preds.every(p => p(req, res)),
|
|
61
|
+
or: (...preds) => (req, res) => preds.some(p => p(req, res)),
|
|
62
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// server/storage.js
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import multer from 'multer';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { makeEvaluator } from './rulesEngine.js';
|
|
7
|
+
|
|
8
|
+
export function createStorageRouter(uploadDestination, bucket, rulesModule) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
const upload = multer({ storage: multer.memoryStorage() });
|
|
11
|
+
|
|
12
|
+
const evaluator = makeEvaluator(rulesModule);
|
|
13
|
+
|
|
14
|
+
// --- Helper functions ---
|
|
15
|
+
function metaPathFor(filePath) {
|
|
16
|
+
return path.join(uploadDestination, `${filePath}.meta.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadMetadata(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
const data = await fs.promises.readFile(metaPathFor(filePath), 'utf8');
|
|
22
|
+
return JSON.parse(data);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function saveMetadata(filePath, meta) {
|
|
29
|
+
await fs.promises.mkdir(path.dirname(metaPathFor(filePath)), { recursive: true });
|
|
30
|
+
await fs.promises.writeFile(metaPathFor(filePath), JSON.stringify(meta, null, 2), 'utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Routes ---
|
|
34
|
+
|
|
35
|
+
// Upload file
|
|
36
|
+
router.post('/upload', upload.single('file'), async (req, res) => {
|
|
37
|
+
const file = req.file;
|
|
38
|
+
const filePath = (req.query.path || '').replace(/^\/+/, '');
|
|
39
|
+
|
|
40
|
+
if (!file) return res.status(400).json({ error: 'Missing file' });
|
|
41
|
+
if (!filePath) return res.status(400).json({ error: 'Missing ?path=' });
|
|
42
|
+
|
|
43
|
+
let incomingMeta = {};
|
|
44
|
+
try {
|
|
45
|
+
incomingMeta = req.body.metadata ? JSON.parse(req.body.metadata) : {};
|
|
46
|
+
} catch {
|
|
47
|
+
incomingMeta = {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const resource = { ...incomingMeta, path: filePath };
|
|
51
|
+
const request = { auth: req.auth, method: req.method, body: req.body, query: req.query, ip: req.ip };
|
|
52
|
+
|
|
53
|
+
// Security check: can this user create this file?
|
|
54
|
+
const allowed = await evaluator.evaluate('files', 'create', request, resource);
|
|
55
|
+
if (!allowed) return res.status(403).json({ error: 'Permission denied' });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const fileObj = bucket.file(filePath);
|
|
59
|
+
await fileObj.save(file.buffer, { contentType: file.mimetype });
|
|
60
|
+
|
|
61
|
+
const owner = incomingMeta.owner || req.auth?.id;
|
|
62
|
+
const allowedUsers = incomingMeta.allowedUsers || [owner];
|
|
63
|
+
|
|
64
|
+
const meta = {
|
|
65
|
+
owner,
|
|
66
|
+
allowedUsers: allowedUsers.map(String),
|
|
67
|
+
contentType: file.mimetype,
|
|
68
|
+
size: file.size,
|
|
69
|
+
createdAt: new Date().toISOString(),
|
|
70
|
+
path: filePath,
|
|
71
|
+
publicUrl: fileObj.publicUrl(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
await saveMetadata(filePath, meta);
|
|
75
|
+
res.status(201).json({ publicUrl: meta.publicUrl, metadata: meta });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error('upload error', err);
|
|
78
|
+
res.status(500).json({ error: 'Failed to save file' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Get file metadata
|
|
83
|
+
router.get('/metadata', async (req, res) => {
|
|
84
|
+
const filePath = (req.query.path || '').replace(/^\/+/, '');
|
|
85
|
+
const meta = await loadMetadata(filePath);
|
|
86
|
+
if (!meta) return res.status(404).json({ error: 'Not found' });
|
|
87
|
+
|
|
88
|
+
const request = { auth: req.auth, method: req.method, query: req.query, ip: req.ip };
|
|
89
|
+
const allowed = await evaluator.evaluate('files', 'read', request, meta);
|
|
90
|
+
if (!allowed) return res.status(403).json({ error: 'Permission denied' });
|
|
91
|
+
|
|
92
|
+
res.json(meta);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Download file (rule-checked)
|
|
96
|
+
router.get('/download', async (req, res) => {
|
|
97
|
+
const filePath = (req.query.path || '').replace(/^\/+/, '');
|
|
98
|
+
const meta = await loadMetadata(filePath);
|
|
99
|
+
if (!meta) return res.status(404).json({ error: 'Not found' });
|
|
100
|
+
|
|
101
|
+
const request = { auth: req.auth, method: req.method, query: req.query, ip: req.ip };
|
|
102
|
+
const allowed = await evaluator.evaluate('files', 'read', request, meta);
|
|
103
|
+
if (!allowed) return res.status(403).json({ error: 'Permission denied' });
|
|
104
|
+
|
|
105
|
+
const fullPath = path.join(uploadDestination, filePath);
|
|
106
|
+
res.sendFile(fullPath);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Delete file
|
|
110
|
+
router.delete('/file', async (req, res) => {
|
|
111
|
+
const filePath = (req.query.path || '').replace(/^\/+/, '');
|
|
112
|
+
const meta = await loadMetadata(filePath);
|
|
113
|
+
if (!meta) return res.status(404).json({ error: 'Not found' });
|
|
114
|
+
|
|
115
|
+
const request = { auth: req.auth, method: req.method, query: req.query, ip: req.ip };
|
|
116
|
+
const allowed = await evaluator.evaluate('files', 'delete', request, meta);
|
|
117
|
+
if (!allowed) return res.status(403).json({ error: 'Permission denied' });
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await fs.promises.unlink(path.join(uploadDestination, filePath));
|
|
121
|
+
await fs.promises.unlink(metaPathFor(filePath)).catch(() => { });
|
|
122
|
+
res.json({ deleted: true });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(err);
|
|
125
|
+
res.status(500).json({ error: 'Delete failed' });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return router;
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Tests
|
|
2
|
+
|
|
3
|
+
Don't forget to create a database and run migrations first
|
|
4
|
+
|
|
5
|
+
## Create Database
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
PGPASSWORD=yoursecretpassword psql -h localhost -U postgres -c 'create database postbase_test;'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run Migrations
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
cd backend/lib/postbase/
|
|
15
|
+
npm run migrate:up
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Run test
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm test
|
|
22
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { startTestServer } from './testServer.js';
|
|
3
|
+
|
|
4
|
+
let srv;
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
srv = await startTestServer();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterAll(() => srv.close());
|
|
11
|
+
|
|
12
|
+
test('PUT + GET node', async () => {
|
|
13
|
+
await request(srv.url)
|
|
14
|
+
.put('/rtdb/users/u1')
|
|
15
|
+
.send({ name: 'Alice' })
|
|
16
|
+
.expect(200);
|
|
17
|
+
|
|
18
|
+
const r = await request(srv.url)
|
|
19
|
+
.get('/rtdb/users/u1')
|
|
20
|
+
.expect(200);
|
|
21
|
+
|
|
22
|
+
expect(r.body).toEqual({ name: 'Alice' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('PATCH merges value', async () => {
|
|
26
|
+
await request(srv.url)
|
|
27
|
+
.patch('/rtdb/users/u1')
|
|
28
|
+
.send({ age: 20 })
|
|
29
|
+
.expect(200);
|
|
30
|
+
|
|
31
|
+
const r = await request(srv.url)
|
|
32
|
+
.get('/rtdb/users/u1')
|
|
33
|
+
.expect(200);
|
|
34
|
+
|
|
35
|
+
expect(r.body).toEqual({ name: 'Alice', age: 20 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('PUSH creates child', async () => {
|
|
39
|
+
const r = await request(srv.url)
|
|
40
|
+
.post('/rtdb/users/u1/posts/push')
|
|
41
|
+
.send({ title: 'hello' })
|
|
42
|
+
.expect(201);
|
|
43
|
+
|
|
44
|
+
expect(r.body.key).toBeDefined();
|
|
45
|
+
expect(r.body.path).toMatch(/users\/u1\/posts\/.+/);
|
|
46
|
+
});
|