From 20f08c172c8aba6f11a609aeb8748f766a572f05 Mon Sep 17 00:00:00 2001 From: GogoVega <92022724+GogoVega@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:47:27 +0100 Subject: [PATCH] Allow UI to set the RTDB `defaultWriteSizeLimit` setting --- build/nodes/firebase-config.html | 78 ++++++++++++++++ .../nodes/locales/en-US/firebase-config.json | 7 +- build/nodes/locales/fr/firebase-config.json | 7 +- src/lib/firebase/client/client.ts | 26 +++++- src/lib/firebase/client/utils.ts | 8 +- src/lib/nodes/rtdb-settings.ts | 93 +++++++++++++++++++ src/nodes/firebase-config.ts | 20 +++- 7 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 src/lib/nodes/rtdb-settings.ts diff --git a/build/nodes/firebase-config.html b/build/nodes/firebase-config.html index 9fb65d3..63d2616 100644 --- a/build/nodes/firebase-config.html +++ b/build/nodes/firebase-config.html @@ -80,6 +80,63 @@ // deprecated $("#node-config-input-json").typedInput({ default: "json", types: ["json"] }); + const timeMap = { small: 10, medium: 30, large: 60 }; + const sizeLimitInput = $("#node-config-input-defaultWriteSizeLimit"); + sizeLimitInput.typedInput({ + type: "size", + types: [ + { + value: "size", // TODO: i18n + options: ["small", "medium", "large", "unlimited"].map((o) => ({ + value: o, + label: o + (timeMap[o] ? ` (${timeMap[o]}s)` : ""), + })), + }, + ], + }); + + let popover = null; + const sizeLimitButton = $("#node-config-button-defaultWriteSizeLimit"); + sizeLimitButton.on("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + + if ($(this).attr("disabled")) return; + $(this).attr("disabled", true); + + const that = this; + $.post(`firebase/config-node/rtdb/settings/${this.id}`, { writeSizeLimit: sizeLimitInput.val() }) + .then(function () { + RED.notify("Successfully updated RTDB settings", "success"); + }) + .fail(function (error) { + console.warn(error.statusText, error.responseText); + RED.notify("Failed to update RTDB settings", "error"); + }) + .always(function () { + $(that).attr("disabled", false); + }); + }); + + const msg = "Only available with PrivateKey authentication"; + authType.on("change", function () { + if (authType.val() === "privateKey") { + sizeLimitButton.attr("disabled", false); + popover?.delete(); + popover = null; + + $.getJSON(`firebase/config-node/rtdb/settings/${this.id}`, function (response) { + sizeLimitInput.typedInput("value", response.defaultWriteSizeLimit); + }); + } else if (popover) { + sizeLimitButton.attr("disabled", true); + popover.setContent(msg); + } else { + sizeLimitButton.attr("disabled", true); + popover = RED.popover.tooltip(sizeLimitButton, msg); + } + }); + const tabs = RED.tabs.create({ id: "node-config-firebase-tabs", onchange: function (tab) { @@ -103,6 +160,11 @@ label: this._("firebase-config.tabs.databases"), }); + tabs.addTab({ + id: "firebase-tab-settings", + label: this._("firebase-config.tabs.settings"), + }); + tabs.activateTab("firebase-tab-connection"); }, oneditsave: function () { @@ -270,5 +332,21 @@ + + + diff --git a/build/nodes/locales/en-US/firebase-config.json b/build/nodes/locales/en-US/firebase-config.json index 68908ae..530bf34 100644 --- a/build/nodes/locales/en-US/firebase-config.json +++ b/build/nodes/locales/en-US/firebase-config.json @@ -13,12 +13,14 @@ "projectId": "Project ID", "storageBucket": "Bucket", "uid": "UID", - "url": "URL" + "url": "URL", + "writeSizeLimit": "Write size limit" }, "tabs": { "connection": "Connection", "security": "Security", - "databases": "Databases" + "databases": "Databases", + "settings": "Settings" }, "divider": { "authMethod": "Authentication Method", @@ -51,6 +53,7 @@ "clickDragHere": "Click or drag the file here.", "dropFileHere": "Drop File Here", "nothing2complete": "Nothing to complete", + "saveSetting": "Save setting", "tooltips": { "apiKey": "API Key can be found in the project settings.", "clientEmail": "Client Email can be found in the JSON file, it is the client_email field.", diff --git a/build/nodes/locales/fr/firebase-config.json b/build/nodes/locales/fr/firebase-config.json index 4c514d9..8e9c664 100644 --- a/build/nodes/locales/fr/firebase-config.json +++ b/build/nodes/locales/fr/firebase-config.json @@ -13,12 +13,14 @@ "projectId": "ID Projet", "storageBucket": "Bucket", "uid": "UID", - "url": "URL" + "url": "URL", + "writeSizeLimit": "Taille max d'écriture" }, "tabs": { "connection": "Connexion", "security": "Sécurité", - "databases": "Base de données" + "databases": "Base de données", + "settings": "Paramètres" }, "divider": { "authMethod": "Méthode d'authentification", @@ -51,6 +53,7 @@ "clickDragHere": "Cliquer ou faites glisser le fichier ici.", "dropFileHere": "Lachez le fichier ici", "nothing2complete": "Rien à compléter", + "saveSetting": "Enregistrer le paramètre", "tooltips": { "apiKey": "La Clé API se trouve dans les paramètres du projet.", "clientEmail": "L'Email Client peut être trouvée dans la console Firebase, dans la section Paramètres du projet, cliquez sur Comptes de service puis sur Clés de compte de service. L'adresse e-mail est affichée dans la colonne Adresse e-mail.", diff --git a/src/lib/firebase/client/client.ts b/src/lib/firebase/client/client.ts index 83027eb..398d7f3 100644 --- a/src/lib/firebase/client/client.ts +++ b/src/lib/firebase/client/client.ts @@ -26,18 +26,20 @@ import { signInWithEmailAndPassword, signOut, } from "@firebase/auth"; -import { App as FirebaseAdminApp, AppOptions, cert } from "firebase-admin/app"; +import { App as FirebaseAdminApp, AppOptions, cert, GoogleOAuthAccessToken, ServiceAccount } from "firebase-admin/app"; +import { Auth as AdminAuth, getAuth as adminGetAuth } from "firebase-admin/auth"; import { TypedEmitter } from "tiny-typed-emitter"; import { ClientError } from "./error"; import { AppConfig, ClientEvents, Credentials, SignInFn, SignState } from "./types"; -import { checkJSONCredential, createCustomToken } from "./utils"; +import { checkJSONCredential, createCustomToken, getAccessToken } from "./utils"; import { AdminApp, App } from "../app"; import { LogCallback, LogFn } from "../logger"; export class Client extends TypedEmitter { private _app?: AdminApp | App; - private _auth?: Auth; + private _auth?: AdminAuth | Auth; private _clientInitialised: boolean = false; + private _serviceAccount?: ServiceAccount; private _signState: SignState = SignState.NOT_YET; private warn: LogFn | null = null; @@ -56,6 +58,10 @@ export class Client extends TypedEmitter { return this._app?.app; } + public get auth(): AdminAuth | Auth | undefined { + return this._auth; + } + public get clientDeleted(): boolean | undefined { return this._app?.deleted; } @@ -89,6 +95,13 @@ export class Client extends TypedEmitter { return this._app.deleteApp(); } + public getAccessToken(): Promise { + if (!this._clientInitialised) throw new ClientError("getAccessToken called before signIn call"); + if (!this._app || !this._app.admin) throw new ClientError("getAccessToken is only allowed for Admin client"); + if (!this._serviceAccount) throw new ClientError("ServiceAccount missing to call getAccessToken"); + return getAccessToken(this._serviceAccount); + } + public signInAnonymously(): Promise { return this.wrapSignIn(() => signInAnonymously(this._auth as Auth)); } @@ -137,7 +150,8 @@ export class Client extends TypedEmitter { } public signInWithPrivateKey(projectId: string, clientEmail: string, privateKey: string): Promise { - const credential = { credential: cert(checkJSONCredential({ clientEmail, privateKey, projectId })) }; + this._serviceAccount = checkJSONCredential({ clientEmail, privateKey, projectId }); + const credential = { credential: cert(this._serviceAccount) }; return this.wrapSignIn({ ...this.appConfig, ...credential }); } @@ -160,7 +174,7 @@ export class Client extends TypedEmitter { this.emit("sign-out"); // Admin SDK has no signOut method - internally called during deleteApp - if (!this.admin && this._auth) await signOut(this._auth); + if (!this.admin && this._auth) await signOut(this._auth as Auth); return this.deleteClient(); } @@ -203,6 +217,8 @@ export class Client extends TypedEmitter { // Initialize the app and sign in this._app = new AdminApp(config, this.appName); + this._auth = adminGetAuth(this._app.app); + // The client is ready to init DB this._clientInitialised = true; diff --git a/src/lib/firebase/client/utils.ts b/src/lib/firebase/client/utils.ts index e7840d1..ede17b7 100644 --- a/src/lib/firebase/client/utils.ts +++ b/src/lib/firebase/client/utils.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { ServiceAccount, cert } from "firebase-admin/app"; +import { GoogleOAuthAccessToken, ServiceAccount, cert } from "firebase-admin/app"; import { getAuth } from "firebase-admin/auth"; import { claimsNotAllowed } from "./constants"; import { Credentials, ServiceAccountId } from "./types"; @@ -56,4 +56,8 @@ async function createCustomToken(cred: Credentials, uid: string, claims?: object return token; } -export { checkJSONCredential, createCustomToken }; +function getAccessToken(serviceAccount: ServiceAccount): Promise { + return cert(checkJSONCredential(serviceAccount)).getAccessToken(); +} + +export { checkJSONCredential, createCustomToken, getAccessToken }; diff --git a/src/lib/nodes/rtdb-settings.ts b/src/lib/nodes/rtdb-settings.ts new file mode 100644 index 0000000..4237427 --- /dev/null +++ b/src/lib/nodes/rtdb-settings.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2023 Gauthier Dandele + * + * Licensed under the MIT License, + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Request, Response } from "express"; +import { ConfigNode } from "./types"; +import { NodeAPI } from "node-red"; +import { isFirebaseConfigNode } from "../firebase/utils"; + +async function getDatabaseSettings(RED: NodeAPI, got: typeof import("got").got, req: Request, res: Response) { + try { + const id = req.params.id; + + if (!id) { + res.status(400).send("The config-node ID is missing!"); + return; + } + + const node = RED.nodes.getNode(id) as ConfigNode | null; + if (!node || !isFirebaseConfigNode(node) || !node.client) { + res.json({}); + return; + } + + const databaseURL = node.credentials.url; + const token = await node.client.getAccessToken(); + + const path = encodeURI(req.body.path || "defaultWriteSizeLimit"); + const url = `${databaseURL}.settings/${path}.json`; + + const response = await got.get(url, { + headers: { Authorization: `Bearer ${token?.access_token}` }, + responseType: "json", + }); + + res.json({ defaultWriteSizeLimit: response.body }); + } catch (error) { + res.status(500).send({ message: String(error) }); + RED.log.error("An error occured while getting RTDB settings: "); + RED.log.error(error); + } +} + +async function updateDatabaseSettings(RED: NodeAPI, got: typeof import("got").got, req: Request, res: Response) { + try { + const id = req.params.id; + + if (!id) { + res.status(400).send("The config-node ID is missing!"); + return; + } + + const node = RED.nodes.getNode(id) as ConfigNode | null; + if (!node || !isFirebaseConfigNode(node) || !node.client) { + res.status(400).send("Config Node disabled or not yet deployed"); + return; + } + + const databaseURL = node.credentials.url; + const token = await node.client.getAccessToken(); + + const path = encodeURI(req.body.path || "defaultWriteSizeLimit"); + const url = `${databaseURL}.settings/${path}.json`; + const writeSizeLimit = req.body.writeSizeLimit; + + const response = await got.post(url, { + headers: { Authorization: `Bearer ${token?.access_token}` }, + body: JSON.stringify(writeSizeLimit), + responseType: "json", + }); + + res.status(200); + console.log(response.body); + } catch (error) { + res.status(500).send({ message: String(error) }); + RED.log.error("An error occured while setting RTDB settings: "); + RED.log.error(error); + } +} + +export { getDatabaseSettings, updateDatabaseSettings }; diff --git a/src/nodes/firebase-config.ts b/src/nodes/firebase-config.ts index a1f943a..e34dbd1 100644 --- a/src/nodes/firebase-config.ts +++ b/src/nodes/firebase-config.ts @@ -15,12 +15,13 @@ */ import { NodeAPI } from "node-red"; +import { getDatabaseSettings, updateDatabaseSettings } from "../lib/nodes/rtdb-settings"; import { FirebaseClient } from "../lib/nodes/firebase-client"; import { Config, ConfigNode } from "../lib/nodes/types"; const VERSION = "0.2.1"; -export default function (RED: NodeAPI) { +export default async function (RED: NodeAPI) { /** * Firebase Configuration Node * @@ -63,4 +64,21 @@ export default function (RED: NodeAPI) { url: { type: "text" }, }, }); + + // Got need a dynamic import due to ESM + // Dynamic imports are transpiled when module is CommonJS + // https://github.com/microsoft/TypeScript/issues/43329 + const got = (await new Function("return import('got')")()).got; + + RED.httpAdmin.get( + "/firebase/config-node/rtdb/settings/:id", + RED.auth.needsPermission("firebase-config.write"), + (req, res) => getDatabaseSettings(RED, got, req, res) + ); + + RED.httpAdmin.post( + "/firebase/config-node/rtdb/settings/:id", + RED.auth.needsPermission("firebase-config.write"), + (req, res) => updateDatabaseSettings(RED, got, req, res) + ); }