const{onCall,HttpsError}= require("firebase-functions/v2/https"); const{initializeApp}= require("firebase-admin/app"); const{getFirestore,FieldValue}= require("firebase-admin/firestore"); const{getStorage}= require("firebase-admin/storage"); initializeApp(); const db = getFirestore(); const storage = getStorage(); // ??? CONFIG ???????????????????????????????????????????????????????????????? // Replace with your actual admin Firebase UID. // You can also store this in Firebase Remote Config or environment variables. const ADMIN_UID = process.env.ADMIN_UID || "REPLACE_WITH_ADMIN_UID"; // Signed URL lifetime for PDF downloads (seconds) const SIGNED_URL_TTL_SECONDS = 60; // ??? HELPERS ??????????????????????????????????????????????????????????????? function assertAdmin(auth){if (!auth?.uid) throw new HttpsError("unauthenticated","You must be signed in.");if (auth.uid !== ADMIN_UID) throw new HttpsError("permission-denied","Admin access required.")}function assertAuth(auth){if (!auth?.uid) throw new HttpsError("unauthenticated","You must be signed in.")}function calcRefund(purchaseData){if (!purchaseData.price || purchaseData.price === 0) return 0;return Number(purchaseData.price)}// ??? approveRemoval ???????????????????????????????????????????????????????? exports.approveRemoval = onCall(async (request) => {assertAdmin(request.auth); const {bookId} = request.data; if (!bookId) throw new HttpsError("invalid-argument","bookId is required."); // 1 Verify book exists and is pending_removal const bookRef = db.collection("books").doc(bookId); const bookSnap = await bookRef.get(); if (!bookSnap.exists) throw new HttpsError("not-found","Book not found."); const book = bookSnap.data(); if (book.status !== "pending_removal") {throw new HttpsError("failed-precondition",`Book status is "${book.status}",expected "pending_removal".`);} // 2 Load all purchases for this book const purchasesSnap = await db.collection("purchases") .where("bookId","==",bookId) .get(); const purchases = purchasesSnap.docs.map(d => ({id: d.id,...d.data()})); const totalRefund = purchases.reduce((sum,p) => sum + calcRefund(p),0); // 3 Batch-update all purchases (admin SDK bypasses Firestore security rules) const BATCH_LIMIT = 499; // Firestore batch limit for (let i = 0; i < purchases.length; i += BATCH_LIMIT) {const batch = db.batch(); purchases.slice(i,i + BATCH_LIMIT).forEach(p => {batch.update(db.collection("purchases").doc(p.id),{accessRevoked: true,refunded: true,refundAmount: calcRefund(p),refundedAt: FieldValue.serverTimestamp(),});}); await batch.commit();} // 4 Mark book as copyright_removed with summary await bookRef.update({status: "copyright_removed","deletionRequest.approvedAt": FieldValue.serverTimestamp(),"deletionRequest.approvedBy": request.auth.uid,"deletionRequest.buyerCount": purchases.length,"deletionRequest.totalRefund": totalRefund.toFixed(2),}); return {success: true,buyerCount: purchases.length,totalRefund: totalRefund.toFixed(2),};}); // ??? getPendingRemovalStats ???????????????????????????????????????????????? exports.getPendingRemovalStats = onCall(async (request) => {assertAdmin(request.auth); const {bookIds} = request.data; if (!bookIds?.length) return {stats: {}}; const stats = {}; await Promise.all(bookIds.map(async bookId => {const snap = await db.collection("purchases") .where("bookId","==",bookId) .get(); const purchases = snap.docs.map(d => d.data()); const total = purchases.reduce((sum,p) => sum + (p.price || 0),0); stats[bookId] = {count: purchases.length,total: total.toFixed(2)};})); return {stats};}); // ??? rejectRemoval ????????????????????????????????????????????????????????? exports.rejectRemoval = onCall(async (request) => {assertAdmin(request.auth); const {bookId} = request.data; if (!bookId) throw new HttpsError("invalid-argument","bookId is required."); const bookRef = db.collection("books").doc(bookId); const bookSnap = await bookRef.get(); if (!bookSnap.exists) throw new HttpsError("not-found","Book not found."); await bookRef.update({status: "listed",deletionRequest: null,}); return {success: true};}); // ??? purchaseBook ?????????????????????????????????????????????????????????? exports.purchaseBook = onCall(async (request) => {assertAuth(request.auth); const {bookId} = request.data; if (!bookId) throw new HttpsError("invalid-argument","bookId is required."); const uid = request.auth.uid; const purchaseId = `${uid}_${bookId}`; const purchaseRef = db.collection("purchases").doc(purchaseId); // Load book to get price/accessType const bookSnap = await db.collection("books").doc(bookId).get(); if (!bookSnap.exists) throw new HttpsError("not-found","Book not found."); const book = bookSnap.data(); if (["copyright_removed","deleted","pending_removal"].includes(book.status)) {throw new HttpsError("failed-precondition","This book is not available for purchase.");} // Use a transaction to atomically check-and-create const alreadyOwned = await db.runTransaction(async (tx) => {const existing = await tx.get(purchaseRef); if (existing.exists) return true; // already purchased,no-op tx.set(purchaseRef,{userId: uid,bookId,bookTitle: book.title,price: book.price,accessType: book.accessType,versionLocked: book.currentVersion || "v1",autoUpdate: false,hasEverUpgraded: false,pendingUpgrade: null,purchasedAt: FieldValue.serverTimestamp(),}); return false;}); return {ok: true,alreadyOwned};}); // ??? SESSION CONSTANTS ????????????????????????????????????????????????????? const SESSION_INACTIVITY_MS = 60 * 60 * 1000; // 1 hour // ??? HELPERS ??????????????????????????????????????????????????????????????? function getDeviceLabel(request){const ua = request.rawRequest?.headers?.["user-agent"] || "";if (/iPhone|iPad/.test(ua)) return "iPhone/iPad";if (/Android/.test(ua)) return "Android device";if (/Mac/.test(ua)) return "Mac";if (/Windows/.test(ua)) return "Windows PC";if (/Linux/.test(ua)) return "Linux";return "Unknown device"}// ??? initSession ??????????????????????????????????????????????????????????? exports.initSession = onCall(async (request) => {assertAuth(request.auth); const {sessionId} = request.data; if (!sessionId) throw new HttpsError("invalid-argument","sessionId is required."); const uid = request.auth.uid; const now = Date.now(); const deviceLabel = getDeviceLabel(request); await db.collection("userSessions").doc(uid).set({sessions: {[sessionId]: {createdAt: now,lastActive: now,deviceLabel,}}},{merge: true}); return {ok: true};}); // ??? openBookSession ???????????????????????????????????????????????????????? exports.openBookSession = onCall(async (request) => {assertAuth(request.auth); const {bookId,sessionId} = request.data; if (!bookId || !sessionId) throw new HttpsError("invalid-argument","bookId and sessionId are required."); const uid = request.auth.uid; const now = Date.now(); // 1 Verify purchase const purchaseSnap = await db.collection("purchases").doc(`${uid}_${bookId}`).get(); if (!purchaseSnap.exists || purchaseSnap.data().accessRevoked) {throw new HttpsError("permission-denied","No valid purchase found.");} // 2 Get book config const bookSnap = await db.collection("books").doc(bookId).get(); if (!bookSnap.exists) throw new HttpsError("not-found","Book not found."); const maxSessions = bookSnap.data().maxConcurrentSessions ?? 1; const userSessionsRef = db.collection("userSessions").doc(uid); const bookAccessRef = db.collection("bookAccess").doc(`${bookId}_${uid}`); await db.runTransaction(async (tx) => {const [userSessionsSnap,bookAccessSnap] = await Promise.all([tx.get(userSessionsRef),tx.get(bookAccessRef),]); // 3 Get valid sessions � remove expired ones const allSessions = userSessionsSnap.data()?.sessions || {}; const validSessionIds = new Set(Object.entries(allSessions) .filter(([,s]) => (now - (s.lastActive || 0)) < SESSION_INACTIVITY_MS) .map(([id]) => id)); // Clean expired sessions from userSessions const expiredSessionIds = Object.keys(allSessions).filter(id => !validSessionIds.has(id)); if (expiredSessionIds.length > 0) {const updatedSessions = {...allSessions}; expiredSessionIds.forEach(id => delete updatedSessions[id]); tx.set(userSessionsRef,{sessions: updatedSessions},{merge: false});} // 4 Get current activeSessions,filter out any that are no longer valid const currentActive = (bookAccessSnap.data()?.activeSessions || []) .filter(id => validSessionIds.has(id)); // Always make sure this session is valid validSessionIds.add(sessionId); // 5 If already in the list � just refresh lastActive,no structural change needed. // This is the same-browser / same-tab re-entry case. Avoid modifying activeSessions // so other tabs" onSnapshot listeners don"t fire a false kicked event. if (currentActive.includes(sessionId)) {tx.set(userSessionsRef,{sessions: {[sessionId]: {lastActive: now}}},{merge: true}); // Still write bookAccess to clean up any expired sessions that were filtered out if (currentActive.length !== (bookAccessSnap.data()?.activeSessions || []).length) {tx.set(bookAccessRef,{activeSessions: currentActive},{merge: false});} return;} // 6 Not in list � enforce limit then append let newActive = [...currentActive]; if (newActive.length >= maxSessions) {// Kick the oldest (first in array) const kicked = newActive.shift(); console.log(`openBookSession: kicked oldest session ${kicked} for uid=${uid} bookId=${bookId}`);} // Append this session as newest newActive.push(sessionId); console.log(`openBookSession: uid=${uid} bookId=${bookId} sessionId=${sessionId} activeSessions=${JSON.stringify(newActive)}`); // Update bookAccess tx.set(bookAccessRef,{activeSessions: newActive},{merge: false}); // Update lastActive for this session in userSessions tx.set(userSessionsRef,{sessions: {[sessionId]: {lastActive: now}}},{merge: true});}); return {ok: true};}); // ??? closeBookSession ??????????????????????????????????????????????????????? exports.closeBookSession = onCall(async (request) => {assertAuth(request.auth); const {bookId,sessionId} = request.data; if (!bookId || !sessionId) throw new HttpsError("invalid-argument","bookId and sessionId are required."); const uid = request.auth.uid; const bookAccessRef = db.collection("bookAccess").doc(`${bookId}_${uid}`); await db.runTransaction(async (tx) => {const snap = await tx.get(bookAccessRef); const current = snap.data()?.activeSessions || []; const updated = current.filter(id => id !== sessionId); tx.set(bookAccessRef,{activeSessions: updated},{merge: false});}); console.log(`closeBookSession: uid=${uid} bookId=${bookId} sessionId=${sessionId}`); return {ok: true};}); // ??? removeSession ?????????????????????????????????????????????????????????? exports.removeSession = onCall(async (request) => {assertAuth(request.auth); const {sessionId} = request.data; if (!sessionId) throw new HttpsError("invalid-argument","sessionId is required."); const uid = request.auth.uid; const userSessionsRef = db.collection("userSessions").doc(uid); await db.runTransaction(async (tx) => {const snap = await tx.get(userSessionsRef); const sessions = snap.data()?.sessions || {}; delete sessions[sessionId]; tx.set(userSessionsRef,{sessions},{merge: false});}); console.log(`removeSession: uid=${uid} sessionId=${sessionId}`); return {ok: true};}); // ??? updateSessionActivity ?????????????????????????????????????????????????? exports.updateSessionActivity = onCall(async (request) => {assertAuth(request.auth); const {sessionId,bookId} = request.data; if (!sessionId) throw new HttpsError("invalid-argument","sessionId is required."); const uid = request.auth.uid; const now = Date.now(); const batch = db.batch(); // Update lastActive in userSessions batch.set(db.collection("userSessions").doc(uid),{sessions: {[sessionId]: {lastActive: now}}},{merge: true}); // If bookId provided,also update bookAccess to confirm session is still active // (no structural change needed � just lastActive in userSessions is enough) await batch.commit(); return {ok: true};}); // ??? downloadPdf ??????????????????????????????????????????????????????????? exports.downloadPdf = onCall(async (request) => {assertAuth(request.auth); const {bookId,version} = request.data; if (!bookId) throw new HttpsError("invalid-argument","bookId is required."); const uid = request.auth.uid; // 1 Load purchase const purchaseId = `${uid}_${bookId}`; const purchaseSnap = await db.collection("purchases").doc(purchaseId).get(); if (!purchaseSnap.exists) {throw new HttpsError("permission-denied","You have not purchased this book.");} const purchase = purchaseSnap.data(); if (purchase.accessRevoked) {throw new HttpsError("permission-denied","Access to this book has been revoked.");} // 2 Load book to get PDF URL const bookSnap = await db.collection("books").doc(bookId).get(); if (!bookSnap.exists) throw new HttpsError("not-found","Book not found."); const book = bookSnap.data(); if (book.status === "copyright_removed") {throw new HttpsError("permission-denied","This book has been removed due to copyright infringement.");} // Resolve the correct PDF path for this version const requestedVersion = version || purchase.versionLocked || book.currentVersion || "v1"; const pdfUrl = book.pdfs?.[requestedVersion] ?? book.pdfUrl; if (!pdfUrl) {throw new HttpsError("not-found","PDF not found for this book version.");} // 3 Extract the Storage path from the download URL and generate a signed URL // Firebase Storage download URLs look like: // https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded-path}?alt=media&token=... let storagePath; try {const urlObj = new url(pdfUrl); // pathname: /v0/b/{bucket}/o/{encoded-path} const parts = urlObj.pathname.split("/o/"); storagePath = decodeURIComponent(parts[1]);} catch {throw new HttpsError("internal","Could not parse PDF storage path.");} const bucket = storage.bucket("wormebook.firebasestorage.app"); const file = bucket.file(storagePath); // Encode filename safely � title may contain non-ASCII (e.g. Hebrew) const safeFilename = encodeURIComponent(`${book.title || "book"}.pdf`); const expiresAt = Date.now() + SIGNED_URL_TTL_SECONDS * 1000; const [signedUrl] = await file.getSignedUrl({action: "read",expires: expiresAt,responseDisposition: `attachment; filename*=UTF-8""${safeFilename}`,}); // 4 Log the download (optional � useful for analytics) await db.collection("downloads").add({userId: uid,bookId,version: requestedVersion,timestamp: FieldValue.serverTimestamp(),}); return {signedUrl,expiresIn: SIGNED_URL_TTL_SECONDS,filename: `${book.title || "book"}.pdf`,};}); // ??? updateBookRating ?????????????????????????????????????????????????????? exports.updateBookRating = onCall(async (request) => {assertAuth(request.auth); const {bookId} = request.data; if (!bookId) throw new HttpsError("invalid-argument","bookId is required."); const snap = await db.collection("reviews") .where("bookId","==",bookId) .where("hidden","==",false) .get(); const ratings = snap.docs.map(d => d.data().rating).filter(Boolean); const avg = ratings.length ? Math.round((ratings.reduce((a,b) => a + b,0) / ratings.length) * 10) / 10 : null; await db.collection("books").doc(bookId).update({avgRating: avg,ratingCount: ratings.length,}); return {ok: true,avg,ratingCount: ratings.length};});{}
