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,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "postbase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "jest"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"better-auth": "^1.3.34",
|
|
10
|
+
"express": "^4.21.2",
|
|
11
|
+
"ws": "^8.20.0",
|
|
12
|
+
"@types/jest": "^30.0.0",
|
|
13
|
+
"jest": "^30.2.0",
|
|
14
|
+
"node-pg-migrate": "^8.0.3",
|
|
15
|
+
"supertest": "^7.1.4"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// frontend/lib/postbase/rtdbClient.js
|
|
2
|
+
export class RtdbClient {
|
|
3
|
+
constructor({ restUrl, wsUrl, getAuthToken }) {
|
|
4
|
+
this.restUrl = restUrl.replace(/\/+$/, '');
|
|
5
|
+
this.wsUrl = wsUrl;
|
|
6
|
+
this.ws = null;
|
|
7
|
+
this.listeners = new Map();
|
|
8
|
+
this.getAuthToken = getAuthToken; // TODO: use this
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async connect() {
|
|
12
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
13
|
+
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
this.ws.addEventListener("open", resolve, { once: true });
|
|
16
|
+
this.ws.addEventListener("error", reject, { once: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
this.ws.addEventListener("message", evt => {
|
|
20
|
+
let msg;
|
|
21
|
+
try { msg = JSON.parse(evt.data); }
|
|
22
|
+
catch { return; }
|
|
23
|
+
|
|
24
|
+
const { path, field, value } = msg;
|
|
25
|
+
const key = field ? `${path}/${field}` : path;
|
|
26
|
+
|
|
27
|
+
if (this.listeners.has(key)) {
|
|
28
|
+
for (const cb of this.listeners.get(key)) cb(value);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Firebase-style ON()
|
|
35
|
+
*/
|
|
36
|
+
on(path, callback) {
|
|
37
|
+
if (!this.listeners.has(path)) {
|
|
38
|
+
this.listeners.set(path, new Set());
|
|
39
|
+
|
|
40
|
+
const parts = path.split("/");
|
|
41
|
+
const isField = parts.length > 2;
|
|
42
|
+
|
|
43
|
+
let type = "sub";
|
|
44
|
+
let payload = { type, path };
|
|
45
|
+
|
|
46
|
+
if (isField) {
|
|
47
|
+
const field = parts.pop();
|
|
48
|
+
const basePath = parts.join("/");
|
|
49
|
+
payload = { type, path: basePath, field };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// prefix listener if path does NOT include a leaf key
|
|
53
|
+
if (path !== "" && !isField && !path.includes("/")) {
|
|
54
|
+
payload = { type, path, prefix: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.ws.send(JSON.stringify(payload));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.listeners.get(path).add(callback);
|
|
61
|
+
|
|
62
|
+
// Return unsubscribe function (like Firebase)
|
|
63
|
+
return () => this.off(path, callback);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Firebase-style OFF()
|
|
68
|
+
*/
|
|
69
|
+
off(path, callback) {
|
|
70
|
+
if (!this.listeners.has(path)) return;
|
|
71
|
+
|
|
72
|
+
if (callback)
|
|
73
|
+
this.listeners.get(path).delete(callback);
|
|
74
|
+
|
|
75
|
+
if (!callback || this.listeners.get(path).size === 0) {
|
|
76
|
+
this.listeners.delete(path);
|
|
77
|
+
|
|
78
|
+
this.ws.send(JSON.stringify({
|
|
79
|
+
type: "unsub",
|
|
80
|
+
path
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// -------------------
|
|
86
|
+
// Write / update
|
|
87
|
+
// -------------------
|
|
88
|
+
async set(path, value) {
|
|
89
|
+
return fetch(`${this.restUrl}/rtdb/${path}`, {
|
|
90
|
+
method: 'PUT',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify(value),
|
|
93
|
+
}).then(r => r.json());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async get(path) {
|
|
97
|
+
return fetch(`${this.restUrl}/rtdb/${path}`)
|
|
98
|
+
.then(r => r.json());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async update(path, value) {
|
|
102
|
+
return fetch(`${this.restUrl}/rtdb/${path}`, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify(value),
|
|
106
|
+
}).then(res => res.json());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// client-storage-sdk.js
|
|
2
|
+
// Minimal Firebase-like client storage SDK for uploading files to an Express backend.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// const storage = createClientStorage('https://api.example.com', () => authToken);
|
|
6
|
+
// const ref = storage.ref('users/123/profile.jpg');
|
|
7
|
+
// const task = ref.put(file, { contentType: 'image/jpeg' });
|
|
8
|
+
// task.on(firebase.storage.TaskEvent.STATE_CHANGED, snapshot => { ... }, error => { ... }, () => { ... });
|
|
9
|
+
|
|
10
|
+
export const TaskEvent = {
|
|
11
|
+
STATE_CHANGED: 'state_changed'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const TaskState = {
|
|
15
|
+
RUNNING: 'running',
|
|
16
|
+
PAUSED: 'paused',
|
|
17
|
+
SUCCESS: 'success',
|
|
18
|
+
ERROR: 'error'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
class UploadSnapshot {
|
|
22
|
+
constructor(bytesTransferred, totalBytes, state, ref, serverResponse = null) {
|
|
23
|
+
this.bytesTransferred = bytesTransferred;
|
|
24
|
+
this.totalBytes = totalBytes;
|
|
25
|
+
this.state = state;
|
|
26
|
+
this.ref = ref;
|
|
27
|
+
this.serverResponse = serverResponse;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class UploadTask {
|
|
32
|
+
constructor(ref, file, metadata, baseUrl, getAuthToken) {
|
|
33
|
+
this.ref = ref;
|
|
34
|
+
this.file = file;
|
|
35
|
+
this.metadata = metadata || {};
|
|
36
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
37
|
+
this.getAuthToken = getAuthToken;
|
|
38
|
+
this._xhr = null;
|
|
39
|
+
this._listeners = []; // to support .on with (snapshotCallback, errorCb, completeCb)
|
|
40
|
+
this._state = TaskState.PAUSED;
|
|
41
|
+
this._bytesTransferred = 0;
|
|
42
|
+
this._totalBytes = file.size;
|
|
43
|
+
this._serverResponse = null;
|
|
44
|
+
this._error = null;
|
|
45
|
+
this._aborted = false;
|
|
46
|
+
// start immediately
|
|
47
|
+
this._start();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_emitSnapshot() {
|
|
51
|
+
const snap = new UploadSnapshot(this._bytesTransferred, this._totalBytes, this._state, this.ref, this._serverResponse);
|
|
52
|
+
for (const l of this._listeners) {
|
|
53
|
+
if (typeof l.snapshot === 'function') {
|
|
54
|
+
try { l.snapshot(snap); } catch (e) { console.error(e); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_emitError(err) {
|
|
60
|
+
this._error = err;
|
|
61
|
+
for (const l of this._listeners) {
|
|
62
|
+
if (typeof l.error === 'function') {
|
|
63
|
+
try { l.error(err); } catch (e) { console.error(e); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_emitComplete() {
|
|
69
|
+
for (const l of this._listeners) {
|
|
70
|
+
if (typeof l.complete === 'function') {
|
|
71
|
+
try { l.complete(); } catch (e) { console.error(e); }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
on(event, snapshotCb, errorCb, completeCb) {
|
|
77
|
+
if (event !== TaskEvent.STATE_CHANGED) {
|
|
78
|
+
throw new Error('Only STATE_CHANGED is supported');
|
|
79
|
+
}
|
|
80
|
+
const listener = { snapshot: snapshotCb, error: errorCb, complete: completeCb };
|
|
81
|
+
this._listeners.push(listener);
|
|
82
|
+
// immediately call snapshot with current state
|
|
83
|
+
if (snapshotCb) {
|
|
84
|
+
try { snapshotCb(new UploadSnapshot(this._bytesTransferred, this._totalBytes, this._state, this.ref, this._serverResponse)); } catch (e) {/* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
// return unsubscribe
|
|
87
|
+
return () => {
|
|
88
|
+
const i = this._listeners.indexOf(listener);
|
|
89
|
+
if (i >= 0) this._listeners.splice(i, 1);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async _start() {
|
|
94
|
+
// Start upload (or resume). Implemented with XHR for progress and abort.
|
|
95
|
+
this._state = TaskState.RUNNING;
|
|
96
|
+
this._aborted = false;
|
|
97
|
+
this._xhr = new XMLHttpRequest();
|
|
98
|
+
const url = `${this.baseUrl}/upload?path=${encodeURIComponent(this.ref.fullPath)}`;
|
|
99
|
+
this._xhr.open('POST', url, true);
|
|
100
|
+
|
|
101
|
+
// Auth header if provided
|
|
102
|
+
const token = this.getAuthToken && await this.getAuthToken();
|
|
103
|
+
if (token) {
|
|
104
|
+
this._xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// We can send metadata as header, or as part of formdata
|
|
108
|
+
const form = new FormData();
|
|
109
|
+
form.append('file', this.file);
|
|
110
|
+
form.append('metadata', JSON.stringify(this.metadata));
|
|
111
|
+
|
|
112
|
+
this._xhr.upload.onprogress = (ev) => {
|
|
113
|
+
if (ev.lengthComputable) {
|
|
114
|
+
this._bytesTransferred = ev.loaded;
|
|
115
|
+
this._totalBytes = ev.total;
|
|
116
|
+
this._emitSnapshot();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
this._xhr.onerror = (ev) => {
|
|
121
|
+
this._state = TaskState.ERROR;
|
|
122
|
+
this._emitSnapshot();
|
|
123
|
+
const err = new Error('Upload failed (network error)');
|
|
124
|
+
this._emitError(err);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
this._xhr.onabort = () => {
|
|
128
|
+
// considered paused if not intentionally cancelled
|
|
129
|
+
if (this._aborted) {
|
|
130
|
+
// paused or canceled - state already set by caller
|
|
131
|
+
this._emitSnapshot();
|
|
132
|
+
} else {
|
|
133
|
+
this._state = TaskState.PAUSED;
|
|
134
|
+
this._emitSnapshot();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
this._xhr.onload = () => {
|
|
139
|
+
if (this._xhr.status >= 200 && this._xhr.status < 300) {
|
|
140
|
+
// success
|
|
141
|
+
this._state = TaskState.SUCCESS;
|
|
142
|
+
try {
|
|
143
|
+
this._serverResponse = JSON.parse(this._xhr.responseText || '{}');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
this._serverResponse = { raw: this._xhr.responseText };
|
|
146
|
+
}
|
|
147
|
+
// set bytesTransferred to total
|
|
148
|
+
this._bytesTransferred = this._totalBytes;
|
|
149
|
+
this._emitSnapshot();
|
|
150
|
+
this._emitComplete();
|
|
151
|
+
} else {
|
|
152
|
+
this._state = TaskState.ERROR;
|
|
153
|
+
const err = new Error(`Upload failed: ${this._xhr.status} ${this._xhr.statusText}`);
|
|
154
|
+
err.status = this._xhr.status;
|
|
155
|
+
try { err.body = JSON.parse(this._xhr.responseText || ''); } catch (e) { }
|
|
156
|
+
this._emitError(err);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this._xhr.send(form);
|
|
161
|
+
this._emitSnapshot();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pause() {
|
|
165
|
+
if (!this._xhr) return;
|
|
166
|
+
if (this._state !== TaskState.RUNNING) return;
|
|
167
|
+
this._aborted = true;
|
|
168
|
+
try { this._xhr.abort(); } catch (e) { }
|
|
169
|
+
this._state = TaskState.PAUSED;
|
|
170
|
+
this._emitSnapshot();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
resume() {
|
|
174
|
+
if (this._state !== TaskState.PAUSED) return;
|
|
175
|
+
// restart upload from scratch
|
|
176
|
+
this._start();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cancel() {
|
|
180
|
+
if (!this._xhr) return;
|
|
181
|
+
this._aborted = true;
|
|
182
|
+
try { this._xhr.abort(); } catch (e) { }
|
|
183
|
+
this._state = TaskState.ERROR;
|
|
184
|
+
const err = new Error('Upload canceled by user');
|
|
185
|
+
err.code = 'storage/canceled';
|
|
186
|
+
this._emitError(err);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// snapshot.ref.getDownloadURL()
|
|
190
|
+
snapshotRefGetDownloadURL() {
|
|
191
|
+
// server response should include publicUrl or downloadUrl
|
|
192
|
+
if (this._serverResponse && this._serverResponse.publicUrl) {
|
|
193
|
+
return Promise.resolve(this._serverResponse.publicUrl);
|
|
194
|
+
}
|
|
195
|
+
// fallback to an endpoint that returns metadata including url
|
|
196
|
+
const url = `${this.baseUrl}/metadata?path=${encodeURIComponent(this.ref.fullPath)}`;
|
|
197
|
+
const token = this.getAuthToken && this.getAuthToken();
|
|
198
|
+
const headers = {};
|
|
199
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
200
|
+
return fetch(url, { headers }).then(res => {
|
|
201
|
+
if (!res.ok) throw new Error(`Failed to get metadata: ${res.status}`);
|
|
202
|
+
return res.json();
|
|
203
|
+
}).then(json => {
|
|
204
|
+
if (json.publicUrl) return json.publicUrl;
|
|
205
|
+
throw new Error('No publicUrl in response');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class StorageRef {
|
|
211
|
+
constructor(fullPath, baseUrl, getAuthToken) {
|
|
212
|
+
this.fullPath = fullPath.replace(/^\/+/, '');
|
|
213
|
+
this.baseUrl = baseUrl;
|
|
214
|
+
this.getAuthToken = getAuthToken;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
put(file, metadata) {
|
|
218
|
+
const task = new UploadTask(this, file, metadata, this.baseUrl, this.getAuthToken);
|
|
219
|
+
|
|
220
|
+
// still allow event-based tracking
|
|
221
|
+
task.snapshot = {
|
|
222
|
+
ref: {
|
|
223
|
+
getDownloadURL: () => task.snapshotRefGetDownloadURL()
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Backward compatability for Firebase Storage
|
|
228
|
+
this.getDownloadURL = () => task.snapshotRefGetDownloadURL();
|
|
229
|
+
|
|
230
|
+
// Wrap the task in a Promise that resolves when upload completes
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
task.on(
|
|
233
|
+
TaskEvent.STATE_CHANGED,
|
|
234
|
+
null,
|
|
235
|
+
(err) => reject(err),
|
|
236
|
+
() => {
|
|
237
|
+
const snap = new UploadSnapshot(
|
|
238
|
+
task._bytesTransferred,
|
|
239
|
+
task._totalBytes,
|
|
240
|
+
task._state,
|
|
241
|
+
task.ref,
|
|
242
|
+
task._serverResponse
|
|
243
|
+
);
|
|
244
|
+
resolve(snap);
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
delete() {
|
|
251
|
+
const url = `${this.baseUrl}/delete?path=${encodeURIComponent(this.fullPath)}`;
|
|
252
|
+
const token = this.getAuthToken && this.getAuthToken();
|
|
253
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
254
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
255
|
+
return fetch(url, { method: 'DELETE', headers }).then(res => {
|
|
256
|
+
if (!res.ok) throw new Error(`Failed to delete file: ${res.status}`);
|
|
257
|
+
return res.json().catch(() => ({}));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createClientStorage({
|
|
263
|
+
baseUrl = '/api/storage',
|
|
264
|
+
getAuthToken = null
|
|
265
|
+
} = {}) {
|
|
266
|
+
baseUrl = baseUrl.replace(/\/+$/, '');
|
|
267
|
+
return {
|
|
268
|
+
ref: (path) => new StorageRef(path, baseUrl, getAuthToken),
|
|
269
|
+
|
|
270
|
+
refFromFile: (fileUrl) => {
|
|
271
|
+
if (typeof fileUrl !== 'string' || !/^https?:\/\//i.test(fileUrl)) {
|
|
272
|
+
throw new Error('refFromFile() expects a valid HTTP or HTTPS URL');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const parsed = new URL(fileUrl);
|
|
277
|
+
// Extract path after `/files/` or fallback to pathname
|
|
278
|
+
let path = parsed.pathname;
|
|
279
|
+
// Common convention: https://api.example.com/files/<path>
|
|
280
|
+
const filesIdx = path.indexOf('/files/');
|
|
281
|
+
if (filesIdx >= 0) {
|
|
282
|
+
path = path.slice(filesIdx + '/files/'.length);
|
|
283
|
+
} else {
|
|
284
|
+
// Remove leading slash if not using /files/ convention
|
|
285
|
+
path = path.replace(/^\/+/, '');
|
|
286
|
+
}
|
|
287
|
+
return new StorageRef(path, baseUrl, getAuthToken);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
throw new Error(`Invalid URL: ${fileUrl}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// tests/rtdb.client.integration.test.js
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { createAuthClient as createBetterAuthClient } from 'better-auth/client';
|
|
4
|
+
import { phoneNumberClient } from "better-auth/client/plugins"
|
|
5
|
+
import { startTestServer } from '../../../../backend/lib/postbase/tests/testServer';
|
|
6
|
+
import { RtdbClient } from '../rtdb';
|
|
7
|
+
import { delay } from '../utils.js';
|
|
8
|
+
import { createAuthClient } from '../auth';
|
|
9
|
+
|
|
10
|
+
let srv;
|
|
11
|
+
let client;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
srv = await startTestServer();
|
|
15
|
+
|
|
16
|
+
const betterAuthClient = createBetterAuthClient({
|
|
17
|
+
baseURL: srv.url + '/auth',
|
|
18
|
+
plugins: [
|
|
19
|
+
phoneNumberClient()
|
|
20
|
+
]
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const auth = createAuthClient(betterAuthClient);
|
|
24
|
+
|
|
25
|
+
client = new RtdbClient({
|
|
26
|
+
restUrl: srv.url,
|
|
27
|
+
wsUrl: srv.wsUrl,
|
|
28
|
+
getAuthToken: auth.getBetterAuthToken,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await client.connect();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
client.off('users/u2');
|
|
36
|
+
client.off('users/u4/status');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
if (srv.wss) srv.wss.close();
|
|
41
|
+
if (srv.server) srv.server.close();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('set + get', async () => {
|
|
45
|
+
await client.set('users/u1', { online: true });
|
|
46
|
+
const v = await client.get('users/u1');
|
|
47
|
+
expect(v.online).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('on fires on change (whole path)', async () => {
|
|
51
|
+
const received = [];
|
|
52
|
+
const unsub = await client.on('users/u2', (val) => received.push(val));
|
|
53
|
+
await client.set('users/u2', { online: true });
|
|
54
|
+
// wait briefly for ws
|
|
55
|
+
await new Promise(r => setTimeout(r, 100));
|
|
56
|
+
expect(received.length).toBe(1);
|
|
57
|
+
expect(received[0].online).toBe(true);
|
|
58
|
+
unsub();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("field listener fires only when changed", async () => {
|
|
62
|
+
const msgs = [];
|
|
63
|
+
|
|
64
|
+
const unsub = await client.on("users/u4/status", v => msgs.push(v));
|
|
65
|
+
|
|
66
|
+
await request(srv.url)
|
|
67
|
+
.put("/rtdb/users/u4")
|
|
68
|
+
.send({ status: "offline" });
|
|
69
|
+
|
|
70
|
+
await delay(50);
|
|
71
|
+
expect(msgs).toContain("offline");
|
|
72
|
+
|
|
73
|
+
msgs.length = 0;
|
|
74
|
+
|
|
75
|
+
await request(srv.url)
|
|
76
|
+
.patch("/rtdb/users/u4")
|
|
77
|
+
.send({ status: "offline" });
|
|
78
|
+
|
|
79
|
+
await request(srv.url)
|
|
80
|
+
.patch("/rtdb/users/u4")
|
|
81
|
+
.send({ status: "online" });
|
|
82
|
+
|
|
83
|
+
await delay(50);
|
|
84
|
+
expect(msgs).toContain("online");
|
|
85
|
+
|
|
86
|
+
unsub();
|
|
87
|
+
});
|
|
88
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function waitFor(fn, timeout = 500) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const start = Date.now();
|
|
4
|
+
const tick = () => {
|
|
5
|
+
if (fn()) return resolve();
|
|
6
|
+
if (Date.now() - start > timeout) {
|
|
7
|
+
return reject(new Error('timeout'));
|
|
8
|
+
}
|
|
9
|
+
setTimeout(tick, 10);
|
|
10
|
+
};
|
|
11
|
+
tick();
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const delay = ms => new Promise(res => setTimeout(res, ms));
|