|
14 | 14 | * limitations under the License. |
15 | 15 | */ |
16 | 16 |
|
17 | | -// Import the Firebase SDK for Google Cloud Functions. |
18 | | -const functions = require('firebase-functions'); |
19 | | -// Import and initialize the Firebase Admin SDK. |
20 | | -const admin = require('firebase-admin'); |
21 | | -admin.initializeApp(); |
22 | | -const Vision = require('@google-cloud/vision'); |
23 | | -const vision = new Vision.ImageAnnotatorClient(); |
24 | | -const {promisify} = require('util'); |
25 | | -const exec = promisify(require('child_process').exec); |
26 | | -const path = require('path'); |
27 | | -const os = require('os'); |
28 | | -const fs = require('fs'); |
29 | | - |
30 | | -// Adds a message that welcomes new users into the chat. |
31 | | -exports.addWelcomeMessages = functions.auth.user().onCreate(async (user) => { |
32 | | - functions.logger.log('A new user signed in for the first time.'); |
33 | | - const fullName = user.displayName || 'Anonymous'; |
34 | | - |
35 | | - // Saves the new welcome message into the database |
36 | | - // which then displays it in the FriendlyChat clients. |
37 | | - await admin.firestore().collection('messages').add({ |
38 | | - name: 'Firebase Bot', |
39 | | - profilePicUrl: '/images/firebase-logo.png', // Firebase logo |
40 | | - text: `${fullName} signed in for the first time! Welcome!`, |
41 | | - timestamp: admin.firestore.FieldValue.serverTimestamp(), |
42 | | - }); |
43 | | - functions.logger.log('Welcome message written to database.'); |
| 17 | +import { auth, logger, storage, firestore } from "firebase-functions/v1"; // Firebase Functions |
| 18 | +import { initializeApp } from "firebase-admin/app"; // App Initialization |
| 19 | +import { getFirestore, FieldValue } from "firebase-admin/firestore"; // Firestore |
| 20 | +import { getStorage } from "firebase-admin/storage"; // Firebase Cloud Storage |
| 21 | +import { getMessaging } from "firebase-admin/messaging"; |
| 22 | +import { |
| 23 | + ImageAnnotatorClient, |
| 24 | + protos as visionProtos, |
| 25 | +} from "@google-cloud/vision"; // Google Vision API |
| 26 | +import { promisify } from "util"; // Node.js Utility for promisifying functions |
| 27 | +import { exec as childExec } from "child_process"; // For running shell commands |
| 28 | +import path from "path"; // For handling file paths |
| 29 | +import os from "os"; // For working with temporary OS files |
| 30 | +import fs from "fs"; // File system module |
| 31 | + |
| 32 | +// Initialize Firebase Admin SDK |
| 33 | +initializeApp(); |
| 34 | + |
| 35 | +const db = getFirestore(); |
| 36 | +const messaging = getMessaging(); |
| 37 | + |
| 38 | +// Initialize Google Vision API Client |
| 39 | +const vision = new ImageAnnotatorClient(); |
| 40 | +const { Likelihood } = visionProtos.google.cloud.vision.v1; // Helper for likelihood comparison |
| 41 | + |
| 42 | +// Promisify exec for async use |
| 43 | +const exec = promisify(childExec); |
| 44 | + |
| 45 | +// ---------------------------- |
| 46 | +// 1. Welcome Message Function |
| 47 | +// ---------------------------- |
| 48 | +export const addWelcomeMessages = auth.user().onCreate(async (user) => { |
| 49 | + logger.log("A new user signed in for the first time."); |
| 50 | + const fullName = user.displayName || "Anonymous"; |
| 51 | + |
| 52 | + try { |
| 53 | + // Add a welcome message to the "messages" collection |
| 54 | + await db.collection("messages").add({ |
| 55 | + name: "Firebase Bot", |
| 56 | + profilePicUrl: "/images/firebase-logo.png", |
| 57 | + text: `${fullName} signed in for the first time! Welcome!`, |
| 58 | + timestamp: FieldValue.serverTimestamp(), |
| 59 | + }); |
| 60 | + logger.log("Welcome message written to Firestore successfully."); |
| 61 | + } catch (error) { |
| 62 | + logger.error("Error writing welcome message to Firestore:", error); |
| 63 | + } |
44 | 64 | }); |
45 | 65 |
|
46 | | -// Checks if uploaded images are flagged as Adult or Violence and if so blurs them. |
47 | | -exports.blurOffensiveImages = functions.runWith({memory: '2GB'}).storage.object().onFinalize( |
48 | | - async (object) => { |
49 | | - const imageUri = `gs://${object.bucket}/${object.name}`; |
50 | | - |
51 | | - // Check the image content using the Cloud Vision API. |
52 | | - const batchAnnotateImagesResponse = await vision.safeSearchDetection(imageUri); |
53 | | - const safeSearchResult = batchAnnotateImagesResponse[0].safeSearchAnnotation; |
54 | | - const Likelihood = Vision.protos.google.cloud.vision.v1.Likelihood; |
55 | | - if (Likelihood[safeSearchResult.adult] >= Likelihood.LIKELY || |
56 | | - Likelihood[safeSearchResult.violence] >= Likelihood.LIKELY) { |
57 | | - functions.logger.log('The image', object.name, 'has been detected as inappropriate.'); |
58 | | - return blurImage(object.name); |
| 66 | +// ------------------------------------------------ |
| 67 | +// 2. Blur Offensive Images Function (Storage API) |
| 68 | +// ------------------------------------------------ |
| 69 | +export const blurOffensiveImages = storage |
| 70 | + .object() |
| 71 | + .onFinalize(async (object) => { |
| 72 | + const fileURI = `gs://${object.bucket}/${object.name}`; // Google Cloud Storage URI |
| 73 | + |
| 74 | + logger.log(`Analyzing image: ${fileURI}`); |
| 75 | + |
| 76 | + try { |
| 77 | + // Run Vision API's SafeSearch Detection to check for inappropriate content |
| 78 | + const [result] = await vision.safeSearchDetection(fileURI); |
| 79 | + const safeSearchAnnotation = result.safeSearchAnnotation; |
| 80 | + |
| 81 | + // Check likelihood of adult or violent content |
| 82 | + if ( |
| 83 | + Likelihood[safeSearchAnnotation?.adult] >= Likelihood.LIKELY || |
| 84 | + Likelihood[safeSearchAnnotation?.violence] >= Likelihood.LIKELY |
| 85 | + ) { |
| 86 | + logger.log( |
| 87 | + `The image "${object.name}" has been marked as inappropriate.` |
| 88 | + ); |
| 89 | + return blurImage(object.name, object.bucket); |
59 | 90 | } |
60 | | - functions.logger.log('The image', object.name, 'has been detected as OK.'); |
61 | | - }); |
62 | 91 |
|
63 | | -// Blurs the given image located in the given bucket using ImageMagick. |
64 | | -async function blurImage(filePath) { |
65 | | - const tempLocalFile = path.join(os.tmpdir(), path.basename(filePath)); |
66 | | - const messageId = filePath.split(path.sep)[1]; |
67 | | - const bucket = admin.storage().bucket(); |
68 | | - |
69 | | - // Download file from bucket. |
70 | | - await bucket.file(filePath).download({destination: tempLocalFile}); |
71 | | - functions.logger.log('Image has been downloaded to', tempLocalFile); |
72 | | - // Blur the image using ImageMagick. |
73 | | - await exec(`convert "${tempLocalFile}" -channel RGBA -blur 0x24 "${tempLocalFile}"`); |
74 | | - functions.logger.log('Image has been blurred'); |
75 | | - // Uploading the Blurred image back into the bucket. |
76 | | - await bucket.upload(tempLocalFile, {destination: filePath}); |
77 | | - functions.logger.log('Blurred image has been uploaded to', filePath); |
78 | | - // Deleting the local file to free up disk space. |
79 | | - fs.unlinkSync(tempLocalFile); |
80 | | - functions.logger.log('Deleted local file.'); |
81 | | - // Indicate that the message has been moderated. |
82 | | - await admin.firestore().collection('messages').doc(messageId).update({moderated: true}); |
83 | | - functions.logger.log('Marked the image as moderated in the database.'); |
| 92 | + logger.log(`The image "${object.name}" is safe.`); |
| 93 | + return null; |
| 94 | + } catch (error) { |
| 95 | + logger.error( |
| 96 | + `Error analyzing the image "${object.name}": ${error.message}` |
| 97 | + ); |
| 98 | + return null; |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | +// ------------------ |
| 103 | +// Helper: Blur Image |
| 104 | +// ------------------ |
| 105 | +async function blurImage(filePath, bucketName) { |
| 106 | + const tempLocalFile = path.join(os.tmpdir(), path.basename(filePath)); // Create temp file path |
| 107 | + const bucket = getStorage().bucket(bucketName); // Get bucket reference |
| 108 | + const messageId = filePath.split("/")[1]; // Derive message ID (assuming structure like "messages/{messageId}/{fileName}") |
| 109 | + |
| 110 | + try { |
| 111 | + // Step 1: Download the file from Firebase Storage |
| 112 | + await bucket.file(filePath).download({ destination: tempLocalFile }); |
| 113 | + logger.log(`Image downloaded locally to: "${tempLocalFile}".`); |
| 114 | + |
| 115 | + // Step 2: Blur the image using ImageMagick |
| 116 | + await exec( |
| 117 | + `convert "${tempLocalFile}" -channel RGBA -blur 0x24 "${tempLocalFile}"` |
| 118 | + ); |
| 119 | + logger.log(`Image blurred locally: "${tempLocalFile}".`); |
| 120 | + |
| 121 | + // Step 3: Upload the blurred image back to Firebase Storage |
| 122 | + await bucket.upload(tempLocalFile, { destination: filePath }); |
| 123 | + logger.log(`Blurred image re-uploaded to bucket at path: "${filePath}".`); |
| 124 | + |
| 125 | + // Step 4: Mark the image as moderated in Firestore |
| 126 | + if (messageId) { |
| 127 | + await db |
| 128 | + .collection("messages") |
| 129 | + .doc(messageId) |
| 130 | + .update({ moderated: true }); |
| 131 | + logger.log(`Marked the image "${filePath}" as moderated in Firestore.`); |
| 132 | + } else { |
| 133 | + logger.warn( |
| 134 | + `Could not derive a valid message ID from filePath: "${filePath}". Skipping Firestore update.` |
| 135 | + ); |
| 136 | + } |
| 137 | + } catch (error) { |
| 138 | + logger.error(`Error in blurring image "${filePath}":`, error); |
| 139 | + } finally { |
| 140 | + // Step 5: Delete the local temporary file |
| 141 | + if (fs.existsSync(tempLocalFile)) { |
| 142 | + fs.unlinkSync(tempLocalFile); |
| 143 | + logger.log("Temporary local file deleted."); |
| 144 | + } |
| 145 | + } |
84 | 146 | } |
85 | 147 |
|
86 | | -// Sends a notifications to all users when a new message is posted. |
87 | | -exports.sendNotifications = functions.firestore.document('messages/{messageId}').onCreate( |
88 | | - async (snapshot) => { |
89 | | - // Notification details. |
90 | | - const text = snapshot.data().text; |
91 | | - const payload = { |
92 | | - notification: { |
93 | | - title: `${snapshot.data().name} posted ${text ? 'a message' : 'an image'}`, |
94 | | - body: text ? (text.length <= 100 ? text : text.substring(0, 97) + '...') : '', |
95 | | - icon: snapshot.data().profilePicUrl || '/images/profile_placeholder.png', |
96 | | - click_action: `https://${process.env.GCLOUD_PROJECT}.firebaseapp.com`, |
97 | | - } |
98 | | - }; |
| 148 | +// --------------------------------------------------- |
| 149 | +// 3. Send Notifications when New Firestore Data Added |
| 150 | +// --------------------------------------------------- |
| 151 | +export const sendNotifications = firestore |
| 152 | + .document("messages/{messageId}") |
| 153 | + .onCreate(async (snapshot) => { |
| 154 | + const messageData = snapshot.data(); |
| 155 | + const text = messageData.text; |
99 | 156 |
|
100 | | - // Get the list of device tokens. |
101 | | - const allTokens = await admin.firestore().collection('fcmTokens').get(); |
102 | | - const tokens = []; |
103 | | - allTokens.forEach((tokenDoc) => { |
104 | | - tokens.push(tokenDoc.id); |
105 | | - }); |
| 157 | + logger.log("New message detected:", messageData); |
| 158 | + |
| 159 | + try { |
| 160 | + // Fetch all available FCM tokens from the "fcmTokens" collection |
| 161 | + const allTokensSnapshot = await db.collection("fcmTokens").get(); |
| 162 | + |
| 163 | + const tokens = []; |
| 164 | + allTokensSnapshot.forEach((tokenDoc) => { |
| 165 | + const token = tokenDoc.data().token; |
| 166 | + if (token) { |
| 167 | + tokens.push(token); |
| 168 | + } |
| 169 | + }); |
106 | 170 |
|
107 | | - if (tokens.length > 0) { |
108 | | - // Send notifications to all tokens. |
109 | | - const response = await admin.messaging().sendToDevice(tokens, payload); |
110 | | - await cleanupTokens(response, tokens); |
111 | | - functions.logger.log('Notifications have been sent and tokens cleaned up.'); |
| 171 | + logger.log("Fetched FCM tokens:", tokens); |
| 172 | + |
| 173 | + if (tokens.length > 0) { |
| 174 | + const responses = await Promise.all( |
| 175 | + tokens.map((token) => |
| 176 | + messaging.send({ |
| 177 | + token: token, |
| 178 | + notification: { |
| 179 | + title: `${messageData.name} posted ${ |
| 180 | + text ? "a message" : "an image" |
| 181 | + }`, |
| 182 | + body: text |
| 183 | + ? text.length <= 100 |
| 184 | + ? text |
| 185 | + : text.substring(0, 97) + "..." |
| 186 | + : "", |
| 187 | + imageUrl: |
| 188 | + messageData.profilePicUrl || |
| 189 | + "/images/profile_placeholder.png", |
| 190 | + }, |
| 191 | + }) |
| 192 | + ) |
| 193 | + ); |
| 194 | + |
| 195 | + logger.log("Send Responses:", responses); |
| 196 | + |
| 197 | + await cleanupTokens(responses, tokens); |
| 198 | + } |
| 199 | + } catch (error) { |
| 200 | + logger.error("Error sending notifications:", error); |
112 | 201 | } |
113 | 202 | }); |
114 | 203 |
|
115 | | -// Cleans up the tokens that are no longer valid. |
116 | | -function cleanupTokens(response, tokens) { |
117 | | - // For each notification we check if there was an error. |
| 204 | +// ------------------- |
| 205 | +// Helper: Cleanup Tokens |
| 206 | +// ------------------- |
| 207 | +const cleanupTokens = async (responses, tokens) => { |
118 | 208 | const tokensDelete = []; |
119 | | - response.results.forEach((result, index) => { |
120 | | - const error = result.error; |
| 209 | + |
| 210 | + responses.forEach((res, index) => { |
| 211 | + const error = res.error; |
121 | 212 | if (error) { |
122 | | - functions.logger.error('Failure sending notification to', tokens[index], error); |
123 | | - // Cleanup the tokens who are not registered anymore. |
124 | | - if (error.code === 'messaging/invalid-registration-token' || |
125 | | - error.code === 'messaging/registration-token-not-registered') { |
126 | | - const deleteTask = admin.firestore().collection('fcmTokens').doc(tokens[index]).delete(); |
127 | | - tokensDelete.push(deleteTask); |
| 213 | + logger.error( |
| 214 | + "Failure sending notification to token:", |
| 215 | + tokens[index], |
| 216 | + error |
| 217 | + ); |
| 218 | + |
| 219 | + // Remove invalid or unregistered tokens |
| 220 | + if ( |
| 221 | + error.code === "messaging/invalid-registration-token" || |
| 222 | + error.code === "messaging/registration-token-not-registered" |
| 223 | + ) { |
| 224 | + const tokenDoc = db.collection("fcmTokens").doc(tokens[index]); |
| 225 | + tokensDelete.push(tokenDoc.delete()); |
128 | 226 | } |
129 | 227 | } |
130 | 228 | }); |
131 | | - return Promise.all(tokensDelete); |
132 | | -} |
| 229 | + |
| 230 | + return Promise.all(tokensDelete); |
| 231 | +}; |
0 commit comments