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,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,9 @@
1
+ import { Pool } from 'pg';
2
+
3
+ export const pool = new Pool({
4
+ connectionString: process.env.TEST_DB_URL,
5
+ });
6
+
7
+ export async function resetDb() {
8
+ await pool.query(`TRUNCATE rtdb_nodes`);
9
+ }
@@ -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
+ });