diff --git a/.github/workflows/readmes-updated.yml b/.github/workflows/readmes-updated.yml index bb1826869..71bfc930b 100644 --- a/.github/workflows/readmes-updated.yml +++ b/.github/workflows/readmes-updated.yml @@ -44,7 +44,7 @@ jobs: echo "::set-output name=dir::$(npm config get prefix)" - name: Cache global dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.global-deps-setup.outputs.dir }} key: diff --git a/README.md b/README.md index e89649eb8..8107f00b2 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,4 @@ You can also browse official Firebase extensions on the [Extensions Marketplace] Documentation for the [Extensions by Firebase](https://firebase.google.com/docs/extensions) section are now stored in this repository. -They can be found under [Docs](https://github.com/firebase/extensions/docs) +They can be found under [Docs](https://github.com/firebase/extensions/tree/master/docs) diff --git a/firestore-send-email/CHANGELOG.md b/firestore-send-email/CHANGELOG.md index 98a9db79a..7268c4d67 100644 --- a/firestore-send-email/CHANGELOG.md +++ b/firestore-send-email/CHANGELOG.md @@ -1,3 +1,11 @@ +## Version 0.1.37 + +feat: add support for OAuth2 authentication + +fix: default replyTo issue introduced in 0.1.35 + +fix: sendgrid attachment bug introduced in 0.1.35 + ## Version 0.1.36 feat - move to Node.js 20 runtimes diff --git a/firestore-send-email/PREINSTALL.md b/firestore-send-email/PREINSTALL.md index b47d4ce0e..1b459fddf 100644 --- a/firestore-send-email/PREINSTALL.md +++ b/firestore-send-email/PREINSTALL.md @@ -39,30 +39,150 @@ Add this document to the Firestore mail collection to send categorized emails. For more details, see the [SendGrid Categories documentation](https://docs.sendgrid.com/ui/sending-email/categories). -#### Setup Google App Passwords +#### Setting Up OAuth2 Authentication + +This section will help you set up OAuth2 authentication for the extension, using GCP (Gmail) as an example. + +The extension is agnostic with respect to OAuth2 provider. You just need to provide it with valid Client ID, Client Secret, and Refresh Token parameters. + +##### Step 1: Create OAuth Credentials in Google Cloud Platform + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project +3. In the left sidebar, navigate to **APIs & Services > Credentials** +4. Click Create Credentials and select **OAuth client ID** +5. Set the application type to **Web application** +6. Give your OAuth client a name (e.g., "Firestore Send Email Extension") +7. Under **Authorized redirect URIs**, add the URI where you'll receive the OAuth callback, for example, `http://localhost:8080/oauth/callback`. + + **Note**: The redirect URI in your OAuth client settings MUST match exactly the callback URL in your code. + +8. Click **Create**. + +##### Step 2: Configure OAuth Consent Screen + +1. In Google Cloud Console, go to **APIs & Services > OAuth consent screen** +2. Choose the appropriate user type: + - **External**: For applications used by any Google user + - **Internal**: For applications used only by users in your organization + +> **Important Note**: If your OAuth consent screen is in "Testing" status, refresh tokens will expire after 7 days unless the User Type is set to "Internal." + +##### Step 3: Generate a Refresh Token -**Google** no longer allows **Gmail** users to use their own passwords to authorize third-party apps and services. Instead, you have to use the [Sign in with App Passwords](https://support.google.com/accounts/answer/185833) service to generate a special password for each app you want to authorize. To do so: +You can use a standalone helper script (`oauth2-refresh-token-helper.js`) that generates a refresh token without requiring any npm installations. + +**Prerequisites:** +- You must have Node.js installed on your machine -1. Go to your [Google Account](https://myaccount.google.com/). -2. Select **Security**. -3. Under "Signing in to Google," select **App Passwords**. You may need to sign in. If you don’t have this option, it might be because: - 1. 2-Step Verification is not set up for your account. - 2. 2-Step Verification is only set up for security keys. - 3. Your account is through work, school, or other organization. - 4. You turned on Advanced Protection. -4. At the bottom, choose **Select app** and choose **Other** option and then write the name of the app password (e.g. `Firebase Trigger Email from Firestore Extension`) and click **Generate**. -5. Follow the instructions to enter the App Password. The App Password is the 16-character code in the yellow bar on your device. -6. Tap **Done**. +**Download the script:** +1. Download the script using curl, wget, or directly from your browser: + ```bash + # Using curl + curl -o oauth2-refresh-token-helper.js https://raw.githubusercontent.com/firebase/extensions/refs/heads/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js + + # Using wget + wget https://raw.githubusercontent.com/firebase/extensions/refs/heads/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js + ``` -Now you can use your Google username with the generated password to authorize the extension. + You can also [view the script on GitHub](https://github.com/firebase/extensions/blob/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js) and download it manually. -#### Setup Hotmail Passwords +> **Note**: If you are creating your own application to obtain a refresh token, in a Node.js environment where you can use npm packages, consider using the official google-auth-library instead: +> +> 1. Install the library: `npm install google-auth-library` +> 2. Then use it like this: +> ```javascript +> import { OAuth2Client } from "google-auth-library"; +> +> // Initialize OAuth client +> const oAuth2Client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +> +> // Generate authorization URL +> const authorizeUrl = oAuth2Client.generateAuthUrl({ +> access_type: "offline", +> prompt: "consent", +> scope: ["https://mail.google.com/"], // Full Gmail access +> }); +> +> // After receiving the code from the callback: +> const { tokens } = await oAuth2Client.getToken(code); +> const refreshToken = tokens.refresh_token; +> ``` -To use your Outlook/Hotmail email account with this extension, you'll need to have 2FA enabled on your account, and [Create an App Password](https://support.microsoft.com/en-us/help/12409/microsoft-account-app-passwords-and-two-step-verification). +2. Run the script with Node.js: -#### Additional setup + ```bash + node oauth2-refresh-token-helper.js + ``` -Before installing this extension, make sure that you've [set up a Cloud Firestore database](https://firebase.google.com/docs/firestore/quickstart) in your Firebase project. +3. The script supports the following command-line options: + ``` + --port, -p Port to run the server on (default: 8080 or PORT env var) + --id, -i Google OAuth Client ID + --secret, -s Google OAuth Client Secret + --output, -o Output file to save the refresh token (default: refresh_token.txt) + --help, -h Show help information + ``` + +4. You can either provide your credentials as command-line arguments or set them as environment variables: + ```bash + # Using environment variables + export CLIENT_ID=your_client_id + export CLIENT_SECRET=your_client_secret + node oauth2-refresh-token-helper.js + + # Using command-line arguments + node oauth2-refresh-token-helper.js --id=your_client_id --secret=your_client_secret + ``` + +5. The script will: + - Start a local web server + - Open your browser to the OAuth consent page + - Receive the authorization code + - Exchange the code for tokens + - Save the refresh token to a file (default: `refresh_token.txt`) + - Display the refresh token in your browser + +6. **Important**: The redirect URI in the script (`http://localhost:8080/oauth/callback` by default) **MUST** match exactly what you configured in the Google Cloud Console OAuth client settings. + +7. The script automatically requests the appropriate scope for Gmail access (`https://mail.google.com/`) and sets the authorization parameters to always receive a refresh token (`access_type: "offline"` and `prompt: "consent"`). + +##### Step 4: Configure the Firestore Send Email Extension + +When installing the extension, select "OAuth2" as the **Authentication Type** and provide the following parameters: + +- **OAuth2 SMTP Host**: `smtp.gmail.com` (for Gmail) +- **OAuth2 SMTP Port**: `465` (for SMTPS) or `587` (for STARTTLS) +- **Use Secure OAuth2 Connection**: `true` (for port 465) or `false` (for port 587) +- **OAuth2 Client ID**: Your Client ID from GCP +- **OAuth2 Client Secret**: Your Client Secret from GCP +- **OAuth2 Refresh Token**: The refresh token generated in Step 3 +- **SMTP User**: Your full Gmail email address + +Leave `Use secure OAuth2 connection?` as the default value `true`. + +##### Troubleshooting + +###### Refresh Token Expiration + +- **Testing Status**: If your OAuth consent screen is in "Testing" status, refresh tokens expire after 7 days unless User Type is set to "Internal" +- **Solution**: Either publish your app or ensure User Type is set to "Internal" in the OAuth consent screen settings + +###### No Refresh Token Received + +- **Problem**: If you don't receive a refresh token during the OAuth flow +- **Solution**: Make sure you've revoked previous access or forced consent by going to [Google Account Security](https://myaccount.google.com/security) > Third-party apps with account access + +###### Scope Issues + +- **Problem**: If you see authentication errors, you might not have the correct scopes +- **Solution**: Ensure you've added `https://mail.google.com/` as a scope in the OAuth consent screen + +###### Additional Resources + +- [Google OAuth2 Documentation](https://developers.google.com/identity/protocols/oauth2) +- [Nodemailer OAuth2 Guide](https://nodemailer.com/smtp/oauth2/) +- [Firebase Extensions Documentation](https://firebase.google.com/docs/extensions) #### Automatic Deletion of Email Documents diff --git a/firestore-send-email/README.md b/firestore-send-email/README.md index 4db28c576..6a1484a71 100644 --- a/firestore-send-email/README.md +++ b/firestore-send-email/README.md @@ -47,30 +47,150 @@ Add this document to the Firestore mail collection to send categorized emails. For more details, see the [SendGrid Categories documentation](https://docs.sendgrid.com/ui/sending-email/categories). -#### Setup Google App Passwords +#### Setting Up OAuth2 Authentication + +This section will help you set up OAuth2 authentication for the extension, using GCP (Gmail) as an example. + +The extension is agnostic with respect to OAuth2 provider. You just need to provide it with valid Client ID, Client Secret, and Refresh Token parameters. + +##### Step 1: Create OAuth Credentials in Google Cloud Platform + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project +3. In the left sidebar, navigate to **APIs & Services > Credentials** +4. Click Create Credentials and select **OAuth client ID** +5. Set the application type to **Web application** +6. Give your OAuth client a name (e.g., "Firestore Send Email Extension") +7. Under **Authorized redirect URIs**, add the URI where you'll receive the OAuth callback, for example, `http://localhost:8080/oauth/callback`. + + **Note**: The redirect URI in your OAuth client settings MUST match exactly the callback URL in your code. + +8. Click **Create**. + +##### Step 2: Configure OAuth Consent Screen + +1. In Google Cloud Console, go to **APIs & Services > OAuth consent screen** +2. Choose the appropriate user type: + - **External**: For applications used by any Google user + - **Internal**: For applications used only by users in your organization + +> **Important Note**: If your OAuth consent screen is in "Testing" status, refresh tokens will expire after 7 days unless the User Type is set to "Internal." + +##### Step 3: Generate a Refresh Token -**Google** no longer allows **Gmail** users to use their own passwords to authorize third-party apps and services. Instead, you have to use the [Sign in with App Passwords](https://support.google.com/accounts/answer/185833) service to generate a special password for each app you want to authorize. To do so: +You can use a standalone helper script (`oauth2-refresh-token-helper.js`) that generates a refresh token without requiring any npm installations. + +**Prerequisites:** +- You must have Node.js installed on your machine -1. Go to your [Google Account](https://myaccount.google.com/). -2. Select **Security**. -3. Under "Signing in to Google," select **App Passwords**. You may need to sign in. If you don’t have this option, it might be because: - 1. 2-Step Verification is not set up for your account. - 2. 2-Step Verification is only set up for security keys. - 3. Your account is through work, school, or other organization. - 4. You turned on Advanced Protection. -4. At the bottom, choose **Select app** and choose **Other** option and then write the name of the app password (e.g. `Firebase Trigger Email from Firestore Extension`) and click **Generate**. -5. Follow the instructions to enter the App Password. The App Password is the 16-character code in the yellow bar on your device. -6. Tap **Done**. +**Download the script:** +1. Download the script using curl, wget, or directly from your browser: + ```bash + # Using curl + curl -o oauth2-refresh-token-helper.js https://raw.githubusercontent.com/firebase/extensions/refs/heads/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js + + # Using wget + wget https://raw.githubusercontent.com/firebase/extensions/refs/heads/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js + ``` -Now you can use your Google username with the generated password to authorize the extension. + You can also [view the script on GitHub](https://github.com/firebase/extensions/blob/master/firestore-send-email/scripts/oauth2-refresh-token-helper.js) and download it manually. -#### Setup Hotmail Passwords +> **Note**: If you are creating your own application to obtain a refresh token, in a Node.js environment where you can use npm packages, consider using the official google-auth-library instead: +> +> 1. Install the library: `npm install google-auth-library` +> 2. Then use it like this: +> ```javascript +> import { OAuth2Client } from "google-auth-library"; +> +> // Initialize OAuth client +> const oAuth2Client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +> +> // Generate authorization URL +> const authorizeUrl = oAuth2Client.generateAuthUrl({ +> access_type: "offline", +> prompt: "consent", +> scope: ["https://mail.google.com/"], // Full Gmail access +> }); +> +> // After receiving the code from the callback: +> const { tokens } = await oAuth2Client.getToken(code); +> const refreshToken = tokens.refresh_token; +> ``` -To use your Outlook/Hotmail email account with this extension, you'll need to have 2FA enabled on your account, and [Create an App Password](https://support.microsoft.com/en-us/help/12409/microsoft-account-app-passwords-and-two-step-verification). +2. Run the script with Node.js: -#### Additional setup + ```bash + node oauth2-refresh-token-helper.js + ``` -Before installing this extension, make sure that you've [set up a Cloud Firestore database](https://firebase.google.com/docs/firestore/quickstart) in your Firebase project. +3. The script supports the following command-line options: + ``` + --port, -p Port to run the server on (default: 8080 or PORT env var) + --id, -i Google OAuth Client ID + --secret, -s Google OAuth Client Secret + --output, -o Output file to save the refresh token (default: refresh_token.txt) + --help, -h Show help information + ``` + +4. You can either provide your credentials as command-line arguments or set them as environment variables: + ```bash + # Using environment variables + export CLIENT_ID=your_client_id + export CLIENT_SECRET=your_client_secret + node oauth2-refresh-token-helper.js + + # Using command-line arguments + node oauth2-refresh-token-helper.js --id=your_client_id --secret=your_client_secret + ``` + +5. The script will: + - Start a local web server + - Open your browser to the OAuth consent page + - Receive the authorization code + - Exchange the code for tokens + - Save the refresh token to a file (default: `refresh_token.txt`) + - Display the refresh token in your browser + +6. **Important**: The redirect URI in the script (`http://localhost:8080/oauth/callback` by default) **MUST** match exactly what you configured in the Google Cloud Console OAuth client settings. + +7. The script automatically requests the appropriate scope for Gmail access (`https://mail.google.com/`) and sets the authorization parameters to always receive a refresh token (`access_type: "offline"` and `prompt: "consent"`). + +##### Step 4: Configure the Firestore Send Email Extension + +When installing the extension, select "OAuth2" as the **Authentication Type** and provide the following parameters: + +- **OAuth2 SMTP Host**: `smtp.gmail.com` (for Gmail) +- **OAuth2 SMTP Port**: `465` (for SMTPS) or `587` (for STARTTLS) +- **Use Secure OAuth2 Connection**: `true` (for port 465) or `false` (for port 587) +- **OAuth2 Client ID**: Your Client ID from GCP +- **OAuth2 Client Secret**: Your Client Secret from GCP +- **OAuth2 Refresh Token**: The refresh token generated in Step 3 +- **SMTP User**: Your full Gmail email address + +Leave `Use secure OAuth2 connection?` as the default value `true`. + +##### Troubleshooting + +###### Refresh Token Expiration + +- **Testing Status**: If your OAuth consent screen is in "Testing" status, refresh tokens expire after 7 days unless User Type is set to "Internal" +- **Solution**: Either publish your app or ensure User Type is set to "Internal" in the OAuth consent screen settings + +###### No Refresh Token Received + +- **Problem**: If you don't receive a refresh token during the OAuth flow +- **Solution**: Make sure you've revoked previous access or forced consent by going to [Google Account Security](https://myaccount.google.com/security) > Third-party apps with account access + +###### Scope Issues + +- **Problem**: If you see authentication errors, you might not have the correct scopes +- **Solution**: Ensure you've added `https://mail.google.com/` as a scope in the OAuth consent screen + +###### Additional Resources + +- [Google OAuth2 Documentation](https://developers.google.com/identity/protocols/oauth2) +- [Nodemailer OAuth2 Guide](https://nodemailer.com/smtp/oauth2/) +- [Firebase Extensions Documentation](https://firebase.google.com/docs/extensions) #### Automatic Deletion of Email Documents @@ -99,6 +219,8 @@ You can find more information about this extension in the following articles: **Configuration Parameters:** +* Authentication Type: The authentication type to be used for the SMTP server (e.g., OAuth2, Username & Password. + * SMTP connection URI: A URI representing an SMTP server this extension can use to deliver email. Note that port 25 is blocked by Google Cloud Platform, so we recommend using port 587 for SMTP connections. If you're using the SMTPS protocol, we recommend using port 465. In order to keep passwords secure, it is recommended to omit the password from the connection string while using the `SMTP Password` field for entering secrets and passwords. Passwords and secrets should now be included in `SMTP password` field. Secure format: `smtps://username@gmail.com@smtp.gmail.com:465` (username only) @@ -109,6 +231,20 @@ password) * SMTP password: User password for the SMTP server +* OAuth2 SMTP Host: The OAuth2 hostname of the SMTP server (e.g., smtp.gmail.com). + +* OAuth2 SMTP Port: The OAuth2 port number for the SMTP server (e.g., 465 for SMTPS, 587 for STARTTLS). + +* Use secure OAuth2 connection?: Set to true to enable a secure connection (TLS/SSL) when using OAuth2 authentication for the SMTP server. + +* OAuth2 Client ID: The OAuth2 Client ID for authentication with the SMTP server. + +* OAuth2 Client Secret: The OAuth2 Client Secret for authentication with the SMTP server. + +* OAuth2 Refresh Token: The OAuth2 Refresh Token for authentication with the SMTP server. + +* OAuth2 SMTP User: The OAuth2 user email or username for SMTP authentication. + * Email documents collection: What is the path to the collection that contains the documents used to build and send the emails? * Default FROM address: The email address to use as the sender's address (if it's not specified in the added email document). You can optionally include a name with the email address (`Friendly Firebaser `). This parameter does not work with [Gmail SMTP](https://nodemailer.com/usage/using-gmail/). diff --git a/firestore-send-email/extension.yaml b/firestore-send-email/extension.yaml index 5149eb4ae..16445f832 100644 --- a/firestore-send-email/extension.yaml +++ b/firestore-send-email/extension.yaml @@ -13,7 +13,7 @@ # limitations under the License. name: firestore-send-email -version: 0.1.36 +version: 0.1.37 specVersion: v1beta displayName: Trigger Email from Firestore @@ -59,6 +59,20 @@ resources: resource: projects/${param:PROJECT_ID}/databases/(default)/documents/${param:MAIL_COLLECTION}/{id} params: + - param: AUTH_TYPE + label: Authentication Type + description: >- + The authentication type to be used for the SMTP server (e.g., OAuth2, + Username & Password. + type: select + options: + - label: Username & Password + value: UsernamePassword + - label: OAuth2 + value: OAuth2 + default: UsernamePassword + required: true + - param: SMTP_CONNECTION_URI label: SMTP connection URI description: >- @@ -78,12 +92,13 @@ params: password) type: string example: smtps://username@smtp.hostname.com:465 - validationRegex: ^(smtp[s]*://(.*?(:[^:@]*)?@)?[^:@]+:[0-9]+(\\?[^ ]*)?)$ + validationRegex: + "^(smtp[s]*://(.*?(:[^:@]*)?@)?[^:@]+:[0-9]+(\\?[^ ]*)?)|^$" validationErrorMessage: Invalid SMTP connection URI. Must be in the form `smtp(s)://username:password@hostname:port` or - `smtp(s)://username@hostname:port`. - required: true + `smtp(s)://username@hostname:port` or to be left blank. + required: false - param: SMTP_PASSWORD label: SMTP password @@ -92,6 +107,64 @@ params: type: secret required: false + - param: HOST + label: OAuth2 SMTP Host + description: >- + The OAuth2 hostname of the SMTP server (e.g., smtp.gmail.com). + type: string + required: false + + - param: OAUTH_PORT + label: OAuth2 SMTP Port + description: >- + The OAuth2 port number for the SMTP server (e.g., 465 for SMTPS, 587 for + STARTTLS). + type: string + required: false + default: 465 + + - param: OAUTH_SECURE + label: Use secure OAuth2 connection? + description: >- + Set to true to enable a secure connection (TLS/SSL) when using OAuth2 + authentication for the SMTP server. + type: select + options: + - label: Yes + value: true + - label: No + value: false + required: false + default: true + + - param: CLIENT_ID + label: OAuth2 Client ID + description: >- + The OAuth2 Client ID for authentication with the SMTP server. + type: secret + required: false + + - param: CLIENT_SECRET + label: OAuth2 Client Secret + description: >- + The OAuth2 Client Secret for authentication with the SMTP server. + type: secret + required: false + + - param: REFRESH_TOKEN + label: OAuth2 Refresh Token + description: >- + The OAuth2 Refresh Token for authentication with the SMTP server. + type: secret + required: false + + - param: USER + label: OAuth2 SMTP User + description: >- + The OAuth2 user email or username for SMTP authentication. + type: string + required: false + - param: MAIL_COLLECTION label: Email documents collection description: >- diff --git a/firestore-send-email/functions/__tests__/config.test.ts b/firestore-send-email/functions/__tests__/config.test.ts index b06f94a6e..bc7b373a3 100644 --- a/firestore-send-email/functions/__tests__/config.test.ts +++ b/firestore-send-email/functions/__tests__/config.test.ts @@ -1,6 +1,6 @@ import * as functionsTestInit from "firebase-functions-test"; import mockedEnv from "mocked-env"; -import { Config } from "../src/types"; +import { AuthenticatonType, Config } from "../src/types"; //@ts-ignore const { config } = global; @@ -23,6 +23,13 @@ const environment = { TTL_EXPIRE_TYPE: "day", TTL_EXPIRE_VALUE: "1", TLS_OPTIONS: "{}", + AUTH_TYPE: AuthenticatonType.OAuth2, + CLIENT_ID: "fake-client-id", + CLIENT_SECRET: "fake-client", + REFRESH_TOKEN: "test-refresh-token", + ACCESS_TOKEN: "test-access", + USER: "test@test.com", + OAUTH_PORT: "465", }; describe("extensions config", () => { @@ -45,6 +52,14 @@ describe("extensions config", () => { TTLExpireType: process.env.TTL_EXPIRE_TYPE, TTLExpireValue: parseInt(process.env.TTL_EXPIRE_VALUE), tls: process.env.TLS_OPTIONS, + host: process.env.HOST, + port: parseInt(process.env.OAUTH_PORT, null), + secure: process.env.SECURE === "true", + authenticationType: process.env.AUTH_TYPE as AuthenticatonType, + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + refreshToken: process.env.REFRESH_TOKEN, + user: process.env.USER, }; const functionsConfig = config(); expect(functionsConfig).toStrictEqual(testConfig); diff --git a/firestore-send-email/functions/__tests__/helpers.test.ts b/firestore-send-email/functions/__tests__/helpers.test.ts index fab797287..2b069d78e 100644 --- a/firestore-send-email/functions/__tests__/helpers.test.ts +++ b/firestore-send-email/functions/__tests__/helpers.test.ts @@ -2,7 +2,7 @@ import Mail = require("nodemailer/lib/mailer"); const { logger } = require("firebase-functions"); import { setSmtpCredentials, isSendGrid } from "../src/helpers"; -import { Config } from "../src/types"; +import { AuthenticatonType, Config } from "../src/types"; const consoleLogSpy = jest.spyOn(logger, "warn").mockImplementation(); @@ -20,6 +20,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -39,6 +40,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -58,6 +60,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -77,6 +80,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -96,6 +100,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -119,6 +124,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); expect(credentials).toBeInstanceOf(Mail); @@ -141,6 +147,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; const credentials = setSmtpCredentials(config); @@ -164,6 +171,7 @@ describe("setSmtpCredentials function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; expect(() => setSmtpCredentials(config)).toThrow(Error); @@ -180,6 +188,7 @@ describe("isSendGrid function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.ApiKey, }; expect(isSendGrid(config)).toBe(true); @@ -192,6 +201,7 @@ describe("isSendGrid function", () => { location: "", mailCollection: "", defaultFrom: "", + authenticationType: AuthenticatonType.UsernamePassword, }; expect(isSendGrid(config)).toBe(false); @@ -205,6 +215,8 @@ test("return invalid smtpConnectionUri credentials with invalid separator", () = location: "", mailCollection: "", defaultFrom: "", + secure: false, + authenticationType: AuthenticatonType.UsernamePassword, }; expect(regex.test(config.smtpConnectionUri)).toBe(false); @@ -216,6 +228,8 @@ test("correctly detects SendGrid SMTP URI", () => { location: "", mailCollection: "", defaultFrom: "", + secure: false, + authenticationType: AuthenticatonType.ApiKey, }; expect(isSendGrid(config)).toBe(true); @@ -224,6 +238,38 @@ test("correctly detects SendGrid SMTP URI", () => { location: "", mailCollection: "", defaultFrom: "", + secure: false, + authenticationType: AuthenticatonType.UsernamePassword, }; expect(isSendGrid(invalidConfig)).toBe(false); }); + +test("correctly uses oAuth credentials when provided", () => { + const config: Config = { + smtpConnectionUri: + "smtps://fakeemail@gmail.com:secret-password@smtp.gmail.com:465", + location: "", + mailCollection: "", + defaultFrom: "", + host: "smtp.gmail.com", + clientId: "fakeClientId", + clientSecret: "fakeClientSecret", + refreshToken: "test_refresh_token", + secure: true, + authenticationType: AuthenticatonType.OAuth2, + user: "test@test.com", + }; + const credentials = setSmtpCredentials(config); + expect(credentials).toBeInstanceOf(Mail); + expect(credentials.options.secure).toBe(true); + expect(credentials.options.host).toBe("smtp.gmail.com"); + expect(credentials.options.auth.type).toBe("OAuth2"); + expect(credentials.options.auth.clientId).toBe("fakeClientId"); + expect(credentials.options.auth.clientSecret).toBe("fakeClientSecret"); + expect(credentials.options.auth.user).toBe("test@test.com"); + expect(credentials.options.auth.refreshToken).toBe("test_refresh_token"); + expect(credentials.options.auth.user).toBe("test@test.com"); + + // The regex should match the smtpConnectionUri, it should be valid + expect(regex.test(config.smtpConnectionUri)).toBe(true); +}); diff --git a/firestore-send-email/functions/src/config.ts b/firestore-send-email/functions/src/config.ts index 04edbf506..bf466d139 100644 --- a/firestore-send-email/functions/src/config.ts +++ b/firestore-send-email/functions/src/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Config } from "./types"; +import { AuthenticatonType, Config } from "./types"; const config: Config = { location: process.env.LOCATION, @@ -29,6 +29,14 @@ const config: Config = { TTLExpireType: process.env.TTL_EXPIRE_TYPE, TTLExpireValue: parseInt(process.env.TTL_EXPIRE_VALUE), tls: process.env.TLS_OPTIONS || "{}", + host: process.env.HOST, + port: parseInt(process.env.OAUTH_PORT, null), + secure: process.env.OAUTH_SECURE === "true", + user: process.env.USER, + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + refreshToken: process.env.REFRESH_TOKEN, + authenticationType: process.env.AUTH_TYPE as AuthenticatonType, }; export default config; diff --git a/firestore-send-email/functions/src/helpers.ts b/firestore-send-email/functions/src/helpers.ts index ef6bffb40..88e2be15c 100644 --- a/firestore-send-email/functions/src/helpers.ts +++ b/firestore-send-email/functions/src/helpers.ts @@ -1,7 +1,8 @@ import { createTransport } from "nodemailer"; import { URL } from "url"; import { invalidTlsOptions, invalidURI } from "./logs"; -import { Config } from "./types"; +import { AuthenticatonType, Config } from "./types"; +import { logger } from "firebase-functions/v1"; function compileUrl($: string): URL | null { try { @@ -30,6 +31,16 @@ export function parseTlsOptions(tlsOptions: string) { } export function setSmtpCredentials(config: Config) { + /** Check if 0Auth2 authentication type */ + if (config.authenticationType === AuthenticatonType.OAuth2) { + /** Return an 0Auth2 based transport */ + + const transporter = setupOAuth2(config); + + logger.info("OAuth2 transport setup successfully"); + return transporter; + } + let url: URL; let transport; @@ -83,3 +94,24 @@ export function isSendGrid(config: Config): boolean { return false; } } + +export function setupOAuth2(config: Config) { + const { clientId, clientSecret, refreshToken, user, host, port, secure } = + config; + try { + return createTransport({ + host, + port, + secure, + auth: { + type: AuthenticatonType.OAuth2, + clientId, + clientSecret, + user, + refreshToken, + }, + }); + } catch (err) { + throw new Error("Error setting up OAuth2 transport"); + } +} diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index 8e16dc2fb..2df4b98ac 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -258,10 +258,10 @@ async function sendWithSendGrid(payload: QueuePayload) { // Transform attachments to match SendGrid's expected format const formatAttachments = ( - attachments: Attachment[] = [] + attachments: QueuePayload["message"]["attachments"] = [] ): SendGridAttachment[] => { return attachments.map((attachment) => ({ - content: attachment.content || "", // Base64-encoded string + content: (attachment.content as string | undefined) || "", // Base64-encoded string filename: attachment.filename || "attachment", type: attachment.contentType, disposition: attachment.contentDisposition, @@ -269,13 +269,17 @@ async function sendWithSendGrid(payload: QueuePayload) { })); }; + const replyTo = { email: payload.replyTo || config.defaultReplyTo }; + + const attachments = payload.message.attachments; + // Build the message object for SendGrid const msg: sgMail.MailDataRequired = { to: formatEmails(payload.to), cc: formatEmails(payload.cc), bcc: formatEmails(payload.bcc), from: { email: payload.from || config.defaultFrom }, - replyTo: { email: payload.replyTo || config.defaultReplyTo }, + replyTo: replyTo.email ? replyTo : undefined, subject: payload.message?.subject, text: typeof payload.message?.text === "string" @@ -287,7 +291,7 @@ async function sendWithSendGrid(payload: QueuePayload) { : undefined, categories: payload.categories, // SendGrid-specific field headers: payload.headers, - attachments: formatAttachments(payload.attachments), // Transform attachments to SendGrid format + attachments: formatAttachments(attachments), // Transform attachments to SendGrid format mailSettings: payload.sendGrid?.mailSettings || {}, // SendGrid-specific mail settings }; diff --git a/firestore-send-email/functions/src/logs.ts b/firestore-send-email/functions/src/logs.ts index 47b1844c4..dbd92fcf3 100644 --- a/firestore-send-email/functions/src/logs.ts +++ b/firestore-send-email/functions/src/logs.ts @@ -21,6 +21,9 @@ import { logger } from "firebase-functions"; export const obfuscatedConfig = Object.assign({}, config, { smtpConnectionUri: "", smtpPassword: "", + clientId: "", + clientSecret: "", + refreshToken: "", }); export function init() { diff --git a/firestore-send-email/functions/src/types.ts b/firestore-send-email/functions/src/types.ts index d82a2024d..e21387e91 100644 --- a/firestore-send-email/functions/src/types.ts +++ b/firestore-send-email/functions/src/types.ts @@ -1,6 +1,8 @@ import * as nodemailer from "nodemailer"; import * as admin from "firebase-admin"; +import * as Handlebars from "handlebars"; +type HandlebarsTemplateDelegate = Handlebars.TemplateDelegate; export interface Config { location: string; mailCollection: string; @@ -14,6 +16,14 @@ export interface Config { TTLExpireType?: string; TTLExpireValue?: number; tls?: string; + host?: Hosts | string; + port?: number; + secure?: boolean; + user?: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + authenticationType: AuthenticatonType; } export interface Attachment { filename?: string; @@ -93,3 +103,16 @@ export type SendGridAttachment = { disposition?: string; contentId?: string; }; + +export enum AuthenticatonType { + OAuth2 = "OAuth2", + UsernamePassword = "UsernamePassword", + ApiKey = "ApiKey", +} + +export enum Hosts { + Gmail = "smtp.gmail.com", + SendGrid = "smtp.sendgrid.net", + Outlook = "smtp-mail.outlook.com", + Hotmail = "smtp.live.com", +} diff --git a/firestore-send-email/scripts/oauth2-refresh-token-helper.js b/firestore-send-email/scripts/oauth2-refresh-token-helper.js new file mode 100644 index 000000000..67fa56900 --- /dev/null +++ b/firestore-send-email/scripts/oauth2-refresh-token-helper.js @@ -0,0 +1,462 @@ +#!/usr/bin/env node + +/** + * Google OAuth Refresh Token Generator + * + * A standalone script that helps obtain a refresh token for Google APIs + * without requiring npm install. + * + * NOTE: An alternative approach is to use the official google-auth-library: + * --------------------------------------------------------------------- + * If you're working in a Node.js environment where you can use npm packages, + * consider using the official google-auth-library instead: + * + * 1. Install the library: npm install google-auth-library + * 2. Then use it like this: + * ``` + * import { OAuth2Client } from "google-auth-library"; + * + * // Initialize OAuth client + * const REDIRECT_URI = "http://localhost:8080/oauth/callback"; + * const oAuth2Client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + * + * // Generate authorization URL + * const authorizeUrl = oAuth2Client.generateAuthUrl({ + * access_type: "offline", + * prompt: "consent", + * scope: ["https://mail.google.com/"], // Full Gmail access + * }); + * + * // After receiving the code from the callback: + * const { tokens } = await oAuth2Client.getToken(code); + * const refreshToken = tokens.refresh_token; + * ``` + * + * This approach integrates better with other Google services and handles + * token refresh automatically when using the library for API calls. + */ + +// Core Node.js modules +const http = require("http"); +const url = require("url"); +const { exec } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); +const os = require("os"); + +// Configuration constants +const DEFAULT_PORT = 8080; +const DEFAULT_OUTPUT_FILE = "refresh_token.txt"; +const AUTH_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes +const SERVER_SHUTDOWN_DELAY_MS = 3000; + +/** + * Parses command line arguments + * @returns {Object} Parsed arguments + */ +function parseArguments() { + const args = process.argv.slice(2); + const config = { + port: DEFAULT_PORT, + clientId: "", + clientSecret: "", + outputFile: DEFAULT_OUTPUT_FILE, + showHelp: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--port": + case "-p": + config.port = parseInt(args[++i], 10) || DEFAULT_PORT; + break; + case "--id": + case "-i": + config.clientId = args[++i] || ""; + break; + case "--secret": + case "-s": + config.clientSecret = args[++i] || ""; + break; + case "--output": + case "-o": + config.outputFile = args[++i] || DEFAULT_OUTPUT_FILE; + break; + case "--help": + case "-h": + config.showHelp = true; + break; + } + } + + return config; +} + +/** + * Shows help information + */ +function displayHelp() { + console.log("Google OAuth Refresh Token Generator"); + console.log("\nThis tool helps you obtain a refresh token for Google APIs."); + console.log( + "It starts a local web server, opens your browser for authentication," + ); + console.log("and saves the refresh token to a file."); + console.log("\nUsage:"); + console.log( + " --port, -p Port to run the server on (default: 8080 or PORT env var)" + ); + console.log(" --id, -i Google OAuth Client ID"); + console.log(" --secret, -s Google OAuth Client Secret"); + console.log( + " --output, -o Output file to save the refresh token (default: refresh_token.txt)" + ); + console.log(" --help, -h Show this help information"); + console.log("\nEnvironment Variables:"); + console.log( + " PORT Port to run the server on (if --port not specified)" + ); + console.log( + " CLIENT_ID Google OAuth Client ID (if --id not specified)" + ); + console.log( + " CLIENT_SECRET Google OAuth Client Secret (if --secret not specified)" + ); +} + +/** + * Opens a browser with the specified URL + * @param {string} url - URL to open + */ +function openBrowser(url) { + console.log("Opening browser..."); + + const commands = { + win32: `start ${url}`, + darwin: `open ${url}`, + default: `xdg-open ${url}`, + }; + + const command = commands[os.platform()] || commands.default; + + exec(command, (error) => { + if (error) { + console.log(`Error opening browser: ${error.message}`); + console.log(`Please open your browser and navigate to ${url}`); + } + }); +} + +/** + * Creates the HTTP server to handle OAuth flow + * @param {Object} options - Server configuration options + * @returns {Promise} The refresh token + */ +function createAuthServer({ + clientId, + clientSecret, + port, + redirectUrl, + outputFile, +}) { + return new Promise((resolve, reject) => { + let codeResolver; + const codePromise = new Promise((resolve) => { + codeResolver = resolve; + }); + + const server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url, true); + const pathname = parsedUrl.pathname; + + // Handle routes + if (pathname === "/") { + handleRootRoute(res, { clientId, redirectUrl }); + } else if (pathname === "/oauth/callback") { + await handleCallbackRoute(req, res, { + clientId, + clientSecret, + redirectUrl, + outputFile, + codeResolver, + server, + resolve, + }); + } else { + // Not found for all other routes + res.writeHead(404); + res.end("Not found"); + } + }); + + // Set a timeout for the entire process + const timeout = setTimeout(() => { + console.log("Timeout waiting for authentication"); + server.close(); + reject(new Error("Authentication timeout")); + }, AUTH_TIMEOUT_MS); + + // Start the server + server.listen(port, () => { + console.log(`Server running at http://localhost:${port}`); + openBrowser(`http://localhost:${port}`); + }); + + // Wait for the code and process it + codePromise + .then((code) => { + console.log("Exchanging code for tokens..."); + clearTimeout(timeout); + }) + .catch((error) => { + clearTimeout(timeout); + server.close(); + reject(error); + }); + }); +} + +/** + * Handles the root route - redirects to Google OAuth + */ +function handleRootRoute(res, { clientId, redirectUrl }) { + const authUrl = + `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${encodeURIComponent(clientId)}` + + `&redirect_uri=${encodeURIComponent(redirectUrl)}` + + `&response_type=code` + + `&scope=${encodeURIComponent("https://mail.google.com/")}` + + `&access_type=offline` + + `&prompt=consent`; + + res.writeHead(302, { Location: authUrl }); + res.end(); +} + +/** + * Handles the OAuth callback route + */ +async function handleCallbackRoute(req, res, options) { + const { + clientId, + clientSecret, + redirectUrl, + outputFile, + codeResolver, + server, + resolve, + } = options; + + const parsedUrl = url.parse(req.url, true); + const code = parsedUrl.query.code; + + if (!code) { + res.writeHead(400); + res.end("No code provided"); + return; + } + + try { + // Notify that we received the code + codeResolver(code); + + // Exchange code for tokens + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUrl, + grant_type: "authorization_code", + }), + }); + + const tokens = await tokenResponse.json(); + const refreshToken = tokens.refresh_token; + + if (!refreshToken) { + sendErrorResponse(res); + return; + } + + // Save the refresh token to file + const tokenFilePath = path.join(process.cwd(), outputFile); + fs.writeFileSync(tokenFilePath, refreshToken, { mode: 0o600 }); + + // Send success response + sendSuccessResponse(res, { refreshToken, outputFile }); + + console.log("\n✅ Successfully obtained refresh token!"); + console.log(`\n✅ Saved refresh token to ${tokenFilePath}`); + + // Wait a bit before shutting down + setTimeout(() => { + console.log("\nToken generation complete! Shutting down..."); + server.close(); + resolve(refreshToken); + }, SERVER_SHUTDOWN_DELAY_MS); + } catch (error) { + console.error("Error exchanging code for token:", error); + res.writeHead(500); + res.end("Error exchanging code for token"); + } +} + +/** + * Sends an error response when no refresh token is received + */ +function sendErrorResponse(res) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(` + + + + + OAuth Error + + + +

Error: No Refresh Token Received

+

Make sure you've revoked previous access or forced consent.

+ + + `); +} + +/** + * Sends a success response with the refresh token + */ +function sendSuccessResponse(res, { refreshToken, outputFile }) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + + + OAuth Refresh Token + + + +

OAuth Successful!

+
✅ Token successfully generated
+

Your OAuth Refresh Token:

+

${refreshToken}

+

Next Steps:

+
    +
  • Your refresh token has been saved to ${outputFile}
  • +
  • Keep this token secure - it provides access to your Gmail account
  • +
  • You can now close this window and the program will exit automatically
  • +
+ + + `); +} + +/** + * Main function to start the OAuth flow + */ +async function main() { + try { + // Initialize config + const config = parseArguments(); + + // Show help if requested + if (config.showHelp) { + displayHelp(); + process.exit(0); + } + + // Setup console display + console.log("===================================="); + console.log("Google OAuth Refresh Token Generator"); + console.log("====================================\n"); + + // Check environment variable if port not provided + if (!config.port) { + const portEnv = process.env.PORT; + if (portEnv) { + const parsedPort = parseInt(portEnv, 10); + if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { + config.port = parsedPort; + } else { + console.log(`Invalid PORT environment variable: ${portEnv}`); + config.port = DEFAULT_PORT; + } + } else { + config.port = DEFAULT_PORT; + } + } + + // Create readline interface for user input + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Get credentials + config.clientId = config.clientId || process.env.CLIENT_ID; + config.clientSecret = config.clientSecret || process.env.CLIENT_SECRET; + + if (!config.clientId) { + config.clientId = await new Promise((resolve) => { + rl.question("Enter your Google OAuth Client ID: ", (answer) => + resolve(answer) + ); + }); + } + + if (!config.clientSecret) { + config.clientSecret = await new Promise((resolve) => { + rl.question("Enter your Google OAuth Client Secret: ", (answer) => + resolve(answer) + ); + }); + } + + if (!config.clientId || !config.clientSecret) { + console.error("Error: Client ID and Client Secret are required"); + rl.close(); + process.exit(1); + } + + // Set up OAuth parameters + const redirectUrl = `http://localhost:${config.port}/oauth/callback`; + console.log(`\nUsing redirect URI: ${redirectUrl}`); + console.log( + "Make sure this exact URI is added to your OAuth consent screen redirects\n" + ); + + // Start the authentication server + await createAuthServer({ + clientId: config.clientId, + clientSecret: config.clientSecret, + port: config.port, + redirectUrl, + outputFile: config.outputFile, + }); + + rl.close(); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +// Run the main function +main().catch((error) => { + console.error("Unhandled error:", error); + process.exit(1); +}); diff --git a/storage-resize-images/CHANGELOG.md b/storage-resize-images/CHANGELOG.md index acb90d4a0..a73dce24f 100644 --- a/storage-resize-images/CHANGELOG.md +++ b/storage-resize-images/CHANGELOG.md @@ -1,3 +1,11 @@ +## Version 0.2.8 + +fixed - support '+' character in paths + +docs - fix typo in PREINSTALL.md + +fixed - handle paths correctly on windows + ## Version 0.2.7 fixed - maintain aspect ratio of resized images (#2115) diff --git a/storage-resize-images/PREINSTALL.md b/storage-resize-images/PREINSTALL.md index 58d1ba91d..7a55ce900 100644 --- a/storage-resize-images/PREINSTALL.md +++ b/storage-resize-images/PREINSTALL.md @@ -16,19 +16,6 @@ The extension supports resizing images in `JPEG`, `PNG`, `WebP`, `GIF`, `AVIF` a The extension can publish a resize completion event, which you can optionally enable when you install the extension. If you enable events, you can write custom event handlers that respond to these events. You can always enable or disable events later. Events will be emitted via Eventarc. Furthermore, you can choose if you want to receive events upon the successful completion of the image resizing. Hence, you can do anything based on the event you will receive. For example, you can use [EventArc gen2 functions](https://firebase.google.com/docs/functions/custom-events#handle-events) to be triggered on events published by the extension. -### Example Event Handler for Successful Resize Operation -```typescript -import * as functions from 'firebase-functions'; -import { onCustomEventPublished } from 'firebase-functions/v2/eventarc'; - -export const onImageResized = onCustomEventPublished( - "firebase.extensions.storage-resize-images.v1.onSuccess", - (event) => { - functions.logger.info("Resize Image is successful", event); - // Additional operations based on the event data can be performed here - return Promise.resolve(); - } -); #### Detailed configuration information @@ -50,6 +37,21 @@ You can install multiple instances of this extension for the same project to con If events are enabled, and you want to create custom event handlers to respond to the events published by the extension, you must ensure that you have the appropriate [role/permissions](https://cloud.google.com/pubsub/docs/access-control#permissions_and_roles) to subscribe to Pub/Sub events. +#### Example Event Handler for Successful Resize Operation +Here is a an example of a custom event handler for events you can choose to emit from this extension: +```typescript +import * as functions from 'firebase-functions'; +import { onCustomEventPublished } from 'firebase-functions/v2/eventarc'; + +export const onImageResized = onCustomEventPublished( + "firebase.extensions.storage-resize-images.v1.onSuccess", + (event) => { + functions.logger.info("Resize Image is successful", event); + // Additional operations based on the event data can be performed here + return Promise.resolve(); + } +); +``` #### Billing To install an extension, your project must be on the [Blaze (pay as you go) plan](https://firebase.google.com/pricing) diff --git a/storage-resize-images/README.md b/storage-resize-images/README.md index d5b438d64..0e6eee5d2 100644 --- a/storage-resize-images/README.md +++ b/storage-resize-images/README.md @@ -24,19 +24,6 @@ The extension supports resizing images in `JPEG`, `PNG`, `WebP`, `GIF`, `AVIF` a The extension can publish a resize completion event, which you can optionally enable when you install the extension. If you enable events, you can write custom event handlers that respond to these events. You can always enable or disable events later. Events will be emitted via Eventarc. Furthermore, you can choose if you want to receive events upon the successful completion of the image resizing. Hence, you can do anything based on the event you will receive. For example, you can use [EventArc gen2 functions](https://firebase.google.com/docs/functions/custom-events#handle-events) to be triggered on events published by the extension. -### Example Event Handler for Successful Resize Operation -```typescript -import * as functions from 'firebase-functions'; -import { onCustomEventPublished } from 'firebase-functions/v2/eventarc'; - -export const onImageResized = onCustomEventPublished( - "firebase.extensions.storage-resize-images.v1.onSuccess", - (event) => { - functions.logger.info("Resize Image is successful", event); - // Additional operations based on the event data can be performed here - return Promise.resolve(); - } -); #### Detailed configuration information @@ -58,6 +45,21 @@ You can install multiple instances of this extension for the same project to con If events are enabled, and you want to create custom event handlers to respond to the events published by the extension, you must ensure that you have the appropriate [role/permissions](https://cloud.google.com/pubsub/docs/access-control#permissions_and_roles) to subscribe to Pub/Sub events. +#### Example Event Handler for Successful Resize Operation +Here is a an example of a custom event handler for events you can choose to emit from this extension: +```typescript +import * as functions from 'firebase-functions'; +import { onCustomEventPublished } from 'firebase-functions/v2/eventarc'; + +export const onImageResized = onCustomEventPublished( + "firebase.extensions.storage-resize-images.v1.onSuccess", + (event) => { + functions.logger.info("Resize Image is successful", event); + // Additional operations based on the event data can be performed here + return Promise.resolve(); + } +); +``` #### Billing To install an extension, your project must be on the [Blaze (pay as you go) plan](https://firebase.google.com/pricing) diff --git a/storage-resize-images/extension.yaml b/storage-resize-images/extension.yaml index fb4c4ddcc..61c031b8a 100644 --- a/storage-resize-images/extension.yaml +++ b/storage-resize-images/extension.yaml @@ -13,7 +13,7 @@ # limitations under the License. name: storage-resize-images -version: 0.2.7 +version: 0.2.8 specVersion: v1beta displayName: Resize Images diff --git a/storage-resize-images/functions/__tests__/util.test.ts b/storage-resize-images/functions/__tests__/util.test.ts index 84294ab99..ff25b85ff 100644 --- a/storage-resize-images/functions/__tests__/util.test.ts +++ b/storage-resize-images/functions/__tests__/util.test.ts @@ -1,4 +1,4 @@ -import { startsWithArray } from "../src/util"; +import { startsWithArray, convertPathToPosix } from "../src/util"; const imagePath = ["/test/picture"]; @@ -49,4 +49,76 @@ describe("startsWithArray function for testing image path", () => { expect(allowResize).toBe(false); }); }); + + it("can handle '+' when replacing '*' in globbed paths", () => { + const allowed = ["/test/picture", "/test/*/picture"]; + const imagePaths = ["/test/picture", "/test/+/picture"]; + + imagePaths.forEach((path) => { + let allowResize = startsWithArray(allowed, path); + expect(allowResize).toBe(true); + }); + }); +}); + +describe("convertPathToPosix function for converting path to posix", () => { + it("converts windows path to posix without drive", () => { + const windowsPaths = [ + "C:\\Users\\test\\image.jpg", + "D:\\Users\\test\\image.jpg", + "E:\\Users\\test\\image.jpg", + "Z:\\Users\\test\\image.jpg", + "C:\\Users\\test:user\\image.jpg", + ]; + + const expectedPosixPaths = [ + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test/image.jpg", + "/Users/test:user/image.jpg", + ]; + + windowsPaths.forEach((windowsPath, index) => { + const outPosixPath = convertPathToPosix(windowsPath, true); + expect(outPosixPath).toBe(expectedPosixPaths[index]); + }); + }); + + it("converts windows path to posix with drive", () => { + const windowsPaths = [ + "C:\\Users\\test\\image.jpg", + "D:\\Users\\test\\image.jpg", + "E:\\Users\\test\\image.jpg", + "Z:\\Users\\test\\image.jpg", + "C:\\Users\\test:user\\image.jpg", + ]; + + const expectedPosixPaths = [ + "C:/Users/test/image.jpg", + "D:/Users/test/image.jpg", + "E:/Users/test/image.jpg", + "Z:/Users/test/image.jpg", + "C:/Users/test:user/image.jpg", + ]; + + windowsPaths.forEach((windowsPath, index) => { + const outPosixPath = convertPathToPosix(windowsPath, false); + expect(outPosixPath).toBe(expectedPosixPaths[index]); + }); + }); + + it("converts posix path to posix (no change)", () => { + const posixPaths = ["/Users/test/image.jpg", "/Users/test:user/image.jpg"]; + + const expectedPosixPaths = [ + "/Users/test/image.jpg", + "/Users/test:user/image.jpg", + ]; + + posixPaths.forEach((posixPath, index) => { + const outPosixPath = convertPathToPosix(posixPath); + expect(outPosixPath).toBe(expectedPosixPaths[index]); + }); + }); }); diff --git a/storage-resize-images/functions/src/filters.ts b/storage-resize-images/functions/src/filters.ts index f1f92295b..1e4e497bc 100644 --- a/storage-resize-images/functions/src/filters.ts +++ b/storage-resize-images/functions/src/filters.ts @@ -3,13 +3,16 @@ import * as path from "path"; import * as logs from "./logs"; import { config } from "./config"; import { supportedContentTypes } from "./resize-image"; -import { startsWithArray } from "./util"; +import { convertPathToPosix, startsWithArray } from "./util"; import { ObjectMetadata } from "firebase-functions/v1/storage"; export function shouldResize(object: ObjectMetadata): boolean { const { contentType } = object; // This is the image MIME type - const tmpFilePath = path.resolve("/", path.dirname(object.name)); // Absolute path to dirname + let tmpFilePath = convertPathToPosix( + path.resolve("/", path.dirname(object.name)), + true + ); // Absolute path to dirname if (!contentType) { logs.noContentType(); diff --git a/storage-resize-images/functions/src/resize-image.ts b/storage-resize-images/functions/src/resize-image.ts index bc2977b64..6b386fe4b 100644 --- a/storage-resize-images/functions/src/resize-image.ts +++ b/storage-resize-images/functions/src/resize-image.ts @@ -9,6 +9,7 @@ import { uuid } from "uuidv4"; import { config } from "./config"; import * as logs from "./logs"; import { ObjectMetadata } from "firebase-functions/v1/storage"; +import { convertPathToPosix } from "./util"; export interface ResizedImageResult { size: string; @@ -284,16 +285,12 @@ export const constructMetadata = ( return metadata; }; -const convertToPosixPath = (filePath: string, locale?: "win32" | "posix") => { - const sep = locale ? path[locale].sep : path.sep; - return filePath.split(sep).join(path.posix.sep); -}; export const getModifiedFilePath = ( fileDir, resizedImagesPath, modifiedFileName ) => { - return convertToPosixPath( + return convertPathToPosix( path.posix.normalize( resizedImagesPath ? path.posix.join(fileDir, resizedImagesPath, modifiedFileName) diff --git a/storage-resize-images/functions/src/util.ts b/storage-resize-images/functions/src/util.ts index c32dbbed2..505da5735 100644 --- a/storage-resize-images/functions/src/util.ts +++ b/storage-resize-images/functions/src/util.ts @@ -1,3 +1,5 @@ +import * as path from "path"; + import { FileMetadata } from "@google-cloud/storage"; import { ObjectMetadata } from "firebase-functions/v1/storage"; @@ -8,7 +10,7 @@ export const startsWithArray = ( for (let userPath of userInputPaths) { const trimmedUserPath = userPath .trim() - .replace(/\*/g, "([a-zA-Z0-9_\\-.\\s\\/]*)?"); + .replace(/\*/g, "([a-zA-Z0-9_\\-\\+.\\s\\/]*)?"); const regex = new RegExp("^" + trimmedUserPath + "(?:/.*|$)"); @@ -23,6 +25,23 @@ export function countNegativeTraversals(path: string): number { return (path.match(/\/\.\.\//g) || []).length; } +export function convertPathToPosix( + filePath: string, + removeDrive?: boolean +): string { + const winSep = path.win32.sep; + const posixSep = path.posix.sep; + + // likely Windows (as contains windows path separator) + if (filePath.includes(winSep) && removeDrive) { + // handle drive (e.g. C:) + filePath = filePath.substring(2); + } + + // replace Windows path separator with posix path separator + return filePath.split(winSep).join(posixSep); +} + export function convertToObjectMetadata( fileMetadata: FileMetadata ): ObjectMetadata {