initial commit
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// pocketbase.js — Firebase-compatible PocketBase adapter
|
||||
// Replace firebase.js with this file. All Firebase-using files work unchanged.
|
||||
//
|
||||
// Swap imports:
|
||||
// find . -name "*.js" -exec sed -i 's|from "./firebase.js"|from "./pocketbase.js"|g' {} +
|
||||
// find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/...|from "./pocketbase.js"|g' {} +
|
||||
//
|
||||
// See firebase-to-pocketbase-migration skill for full migration guide.
|
||||
|
||||
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs";
|
||||
|
||||
// ─── PocketBase Init ───────────────────────────────────────────────
|
||||
const BASE_URL = window.location.origin;
|
||||
const pb = new PocketBase(BASE_URL);
|
||||
|
||||
// ─── Auth (Firebase-compatible surface) ────────────────────────────
|
||||
const auth = {
|
||||
get currentUser() {
|
||||
if (!pb.authStore.isValid) return null;
|
||||
const model = pb.authStore.model;
|
||||
// Only return users from 'users' collection, not superusers/admins
|
||||
if (model.collectionName !== 'users') return null;
|
||||
return {
|
||||
uid: model.id,
|
||||
email: model.email,
|
||||
displayName: model.name || null
|
||||
};
|
||||
},
|
||||
|
||||
onAuthStateChanged(callback) {
|
||||
// Fire initial state synchronously (like Firebase does)
|
||||
const current = this.currentUser;
|
||||
callback(current);
|
||||
|
||||
// Listen for future auth changes
|
||||
pb.authStore.onChange((token, model) => {
|
||||
const user = (model && model.collectionName === 'users') ? {
|
||||
uid: model.id,
|
||||
email: model.email,
|
||||
displayName: model.name || null
|
||||
} : null;
|
||||
callback(user);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Standalone Auth Functions ─────────────────────────────────────
|
||||
function onAuthStateChanged(authRef, callback) { return auth.onAuthStateChanged(callback); }
|
||||
|
||||
async function signInWithEmailAndPassword(a, email, password) {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
return { user: auth.currentUser };
|
||||
}
|
||||
|
||||
async function createUserWithEmailAndPassword(a, email, password) {
|
||||
await pb.collection('users').create({
|
||||
email, password, passwordConfirm: password, emailVisibility: true
|
||||
});
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
return { user: auth.currentUser };
|
||||
}
|
||||
|
||||
async function signOut(a) { pb.authStore.clear(); }
|
||||
|
||||
async function sendPasswordResetEmail(a, email) {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
}
|
||||
|
||||
async function updateProfile(user, data) {
|
||||
const updateData = {};
|
||||
if (data.displayName) updateData.name = data.displayName;
|
||||
await pb.collection('users').update(user.uid, updateData);
|
||||
return { ...user, displayName: data.displayName || user.displayName };
|
||||
}
|
||||
|
||||
async function updatePassword(user, newPassword) {
|
||||
await pb.collection('users').update(user.uid, {
|
||||
password: newPassword, passwordConfirm: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
async function reauthenticateWithCredential(user, credential) {
|
||||
await pb.collection('users').authWithPassword(credential._email, credential._password);
|
||||
}
|
||||
|
||||
const EmailAuthProvider = {
|
||||
credential: (email, password) => ({ _email: email, _password: password })
|
||||
};
|
||||
|
||||
// ─── Firestore-compatible DB Surface ───────────────────────────────
|
||||
const db = {};
|
||||
|
||||
function collection(parent, name, ...subPath) {
|
||||
if (subPath.length === 0) {
|
||||
return { _type: 'collection', _name: name, _path: [name] };
|
||||
}
|
||||
const collectionName = subPath[subPath.length - 1];
|
||||
return { _type: 'collection', _name: collectionName, _path: [name, ...subPath], _userId: subPath[0] };
|
||||
}
|
||||
|
||||
function doc(db, ...path) {
|
||||
if (path.length === 2) return { _type: 'doc', _collection: path[0], _id: path[1] };
|
||||
return { _type: 'doc', _collection: path[path.length - 2], _id: path[path.length - 1], _userId: path[1] };
|
||||
}
|
||||
|
||||
// ─── Query Builder ─────────────────────────────────────────────────
|
||||
function where(field, op, value) { return { _type: 'where', field, op, value }; }
|
||||
function orderBy(field, direction) { return { _type: 'orderBy', field, direction }; }
|
||||
function limit(n) { return { _type: 'limit', value: n }; }
|
||||
function query(collRef, ...constraints) { return { _type: 'query', collection: collRef, constraints }; }
|
||||
|
||||
// ─── PocketBase Filter Builder ─────────────────────────────────────
|
||||
const OP_MAP = {'==': '=', '!=': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=', 'in': '?=', 'not-in': '?!='};
|
||||
|
||||
function buildFilter(constraints) {
|
||||
const parts = [];
|
||||
for (const c of constraints) {
|
||||
if (c._type !== 'where') continue;
|
||||
const pbOp = OP_MAP[c.op] || '=';
|
||||
if (pbOp === '?=' && Array.isArray(c.value)) {
|
||||
const orParts = c.value.map(v => typeof v === 'string' ? `${c.field} ?= "${v}"` : `${c.field} ?= ${v}`);
|
||||
parts.push(`(${orParts.join(' || ')})`);
|
||||
} else if (pbOp === '?=') {
|
||||
const v = typeof c.value === 'string' ? `"${c.value}"` : c.value;
|
||||
parts.push(`${c.field} ${pbOp} ${v}`);
|
||||
} else {
|
||||
const v = typeof c.value === 'string' ? `"${c.value}"` : c.value;
|
||||
parts.push(`${c.field} ${pbOp} ${v}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' && ') : '';
|
||||
}
|
||||
|
||||
function buildSort(constraints) {
|
||||
return constraints.filter(c => c._type === 'orderBy').map(c => (c.direction === 'desc' ? '-' : '+') + c.field);
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ───────────────────────────────────────────────
|
||||
async function getDocs(q) {
|
||||
const collName = q.collection._name;
|
||||
const filter = buildFilter(q.constraints);
|
||||
const sort = buildSort(q.constraints);
|
||||
const limitVal = q.constraints.find(c => c._type === 'limit');
|
||||
const options = { requestKey: null };
|
||||
if (filter) options.filter = filter;
|
||||
if (sort.length > 0) options.sort = sort.join(',');
|
||||
if (limitVal) options.perPage = limitVal.value;
|
||||
|
||||
try {
|
||||
const records = limitVal
|
||||
? await pb.collection(collName).getList(1, limitVal.value, options)
|
||||
: await pb.collection(collName).getFullList(options);
|
||||
const items = limitVal ? records.items : records;
|
||||
|
||||
const docs = items.map(rec => ({
|
||||
id: rec.id, data: () => convertFromPB(rec), exists: true, ref: { id: rec.id }
|
||||
}));
|
||||
docs.forEach = function(cb) { this.forEach(cb); };
|
||||
return docs;
|
||||
} catch (err) {
|
||||
console.warn(`PocketBase getDocs(${collName}):`, err.message);
|
||||
const empty = []; empty.forEach = function(cb) {}; return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function addDoc(collRef, data) {
|
||||
const record = await pb.collection(collRef._name).create(convertToPB(data));
|
||||
return { id: record.id };
|
||||
}
|
||||
|
||||
async function updateDoc(docRef, data) {
|
||||
await pb.collection(docRef._collection).update(docRef._id, convertToPB(data));
|
||||
}
|
||||
|
||||
async function deleteDoc(docRef) {
|
||||
await pb.collection(docRef._collection).delete(docRef._id);
|
||||
}
|
||||
|
||||
async function getDoc(docRef) {
|
||||
try {
|
||||
const record = await pb.collection(docRef._collection).getOne(docRef._id);
|
||||
return { id: record.id, data: () => convertFromPB(record), exists: true };
|
||||
} catch (err) { return { exists: false, data: () => null }; }
|
||||
}
|
||||
|
||||
function setDoc(docRef, data) {
|
||||
const pbData = convertToPB(data);
|
||||
return pb.collection(docRef._collection).update(docRef._id, pbData).catch(() =>
|
||||
pb.collection(docRef._collection).create({ ...pbData, id: docRef._id })
|
||||
).then(rec => ({ id: rec.id }));
|
||||
}
|
||||
|
||||
function onSnapshot(queryOrDocRef, callback) {
|
||||
if (queryOrDocRef._type === 'query') {
|
||||
getDocs(queryOrDocRef).then(docs => callback({ docs, forEach: cb => docs.forEach(cb) }));
|
||||
} else {
|
||||
getDoc(queryOrDocRef).then(doc => callback(doc));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// ─── Timestamps ────────────────────────────────────────────────────
|
||||
function serverTimestamp() { return new Date().toISOString(); }
|
||||
|
||||
class Timestamp {
|
||||
constructor(date) { this._date = date || new Date(); }
|
||||
toDate() { return this._date; }
|
||||
toMillis() { return this._date.getTime(); }
|
||||
}
|
||||
|
||||
// ─── Data Conversion ───────────────────────────────────────────────
|
||||
// Add your app's timestamp field names here
|
||||
const TIMESTAMP_FIELDS = new Set([
|
||||
'writeupTime', 'createdAt', 'updatedAt', 'lastUpdated',
|
||||
'appointmentDateTime', 'completionTime', 'lastActivity',
|
||||
'timestamp', 'dateCreated', 'startDate', 'endDate', 'deadline'
|
||||
]);
|
||||
|
||||
function convertToPB(data) {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === undefined) continue;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
||||
result[key] = JSON.stringify(value);
|
||||
} else if (value instanceof Date) {
|
||||
result[key] = value.toISOString();
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertFromPB(record) {
|
||||
const data = { ...record };
|
||||
for (const field of TIMESTAMP_FIELDS) {
|
||||
if (typeof data[field] === 'string' && data[field].match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
data[field] = new Timestamp(new Date(data[field]));
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
|
||||
try { const p = JSON.parse(value); if (typeof p === 'object') data[key] = p; } catch (e) {}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Exports ───────────────────────────────────────────────────────
|
||||
export { auth, db };
|
||||
export {
|
||||
signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut,
|
||||
sendPasswordResetEmail, updateProfile, onAuthStateChanged,
|
||||
updatePassword, reauthenticateWithCredential, EmailAuthProvider
|
||||
};
|
||||
export {
|
||||
collection, doc, query, where, orderBy, limit,
|
||||
getDocs, getDoc, addDoc, updateDoc, deleteDoc, setDoc, onSnapshot,
|
||||
serverTimestamp, Timestamp
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
# Same-Origin nginx Config for PocketBase Apps
|
||||
# Serves static files AND proxies /api/* to PocketBase from one port.
|
||||
# The app uses `new PocketBase(window.location.origin)` — zero CORS.
|
||||
#
|
||||
# Place in /etc/nginx/sites-available/<app-name>
|
||||
# Symlink: ln -s /etc/nginx/sites-available/<app-name> /etc/nginx/sites-enabled/
|
||||
# Test: nginx -t && systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen <PORT> ssl;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Static app files
|
||||
root /path/to/your/app;
|
||||
index index.html;
|
||||
|
||||
# PocketBase API (same origin = no CORS needed)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:<PB_PORT>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket for PocketBase realtime
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# PocketBase admin UI
|
||||
location /_/ {
|
||||
proxy_pass http://127.0.0.1:<PB_PORT>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback: all paths → index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static file caching (30 days for hashed assets)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
Reference in New Issue
Block a user