Skip to content

Commit

Permalink
Allow UI to set the RTDB defaultWriteSizeLimit setting (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
GogoVega authored Feb 23, 2025
1 parent 53af270 commit a23fd4e
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 12 deletions.
78 changes: 78 additions & 0 deletions build/nodes/firebase-config.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)` : ""),
})),
},
],
});

const node = this;
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);

$.post(`firebase/config-node/rtdb/settings/${node.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 () {
sizeLimitButton.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/${node.id}`, function (response) {
sizeLimitInput.typedInput("value", response.defaultWriteSizeLimit || "large");
});
} 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) {
Expand All @@ -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 () {
Expand Down Expand Up @@ -270,5 +332,21 @@
<span data-i18n="firebase-config.useStatus">
</div>
</div>

<!-- Settings Tab -->
<div id="firebase-tab-settings" style="display:none;">
<div class="firebase-text-divider" data-i18n="firebase-config.divider.rtdb"></div>

<div class="form-row" style="display: flex; align-items: center;">
<label for="node-config-input-defaultWriteSizeLimit"><i class="fa fa-cogs"></i> <span data-i18n="firebase-config.label.writeSizeLimit"></span></label>
<div style="width: 70%; display: inline-flex; align-items: center;">
<div style="flex-grow: 1;">
<input id="node-config-input-defaultWriteSizeLimit" style="width: 100%;"/>
</div>
<button id="node-config-button-defaultWriteSizeLimit" class="red-ui-button red-ui-button-small" style="width: auto; margin-left: 10px;"><span data-i18n="firebase-config.saveSetting"></span></button>
</div>
<div title="Link to Firebase documentation" style="margin-left: 10px;"><a href="https://firebase.google.com/docs/reference/rest/database?hl=en-us#section-param-writesizelimit"><i class="fa fa-external-link-square"></i></a></div>
</div>
</div>
</div>
</script>
7 changes: 5 additions & 2 deletions build/nodes/locales/en-US/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -51,6 +53,7 @@
"clickDragHere": "Click or drag the file here.",
"dropFileHere": "Drop File Here",
"nothing2complete": "Nothing to complete",
"saveSetting": "Save setting",
"tooltips": {
"apiKey": "<strong>API Key</strong> can be found in the project settings.",
"clientEmail": "<strong>Client Email</strong> can be found in the JSON file, it is the <strong>client_email</strong> field.",
Expand Down
7 changes: 5 additions & 2 deletions build/nodes/locales/fr/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <strong>Clé API</strong> se trouve dans les paramètres du projet.",
"clientEmail": "L'<strong>Email Client</strong> peut être trouvée dans la console Firebase, dans la section <strong>Paramètres du projet</strong>, cliquez sur <strong>Comptes de service</strong> puis sur <strong>Clés de compte de service</strong>. L'adresse e-mail est affichée dans la colonne <strong>Adresse e-mail</strong>.",
Expand Down
26 changes: 21 additions & 5 deletions src/lib/firebase/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientEvents> {
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;

Expand All @@ -56,6 +58,10 @@ export class Client extends TypedEmitter<ClientEvents> {
return this._app?.app;
}

public get auth(): AdminAuth | Auth | undefined {
return this._auth;
}

public get clientDeleted(): boolean | undefined {
return this._app?.deleted;
}
Expand Down Expand Up @@ -89,6 +95,13 @@ export class Client extends TypedEmitter<ClientEvents> {
return this._app.deleteApp();
}

public getAccessToken(): Promise<GoogleOAuthAccessToken> {
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<UserCredential> {
return this.wrapSignIn(() => signInAnonymously(this._auth as Auth));
}
Expand Down Expand Up @@ -137,7 +150,8 @@ export class Client extends TypedEmitter<ClientEvents> {
}

public signInWithPrivateKey(projectId: string, clientEmail: string, privateKey: string): Promise<void> {
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 });
}

Expand All @@ -160,7 +174,7 @@ export class Client extends TypedEmitter<ClientEvents> {
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();
}
Expand Down Expand Up @@ -203,6 +217,8 @@ export class Client extends TypedEmitter<ClientEvents> {
// 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;

Expand Down
8 changes: 6 additions & 2 deletions src/lib/firebase/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -56,4 +56,8 @@ async function createCustomToken(cred: Credentials, uid: string, claims?: object
return token;
}

export { checkJSONCredential, createCustomToken };
function getAccessToken(serviceAccount: ServiceAccount): Promise<GoogleOAuthAccessToken> {
return cert(checkJSONCredential(serviceAccount)).getAccessToken();
}

export { checkJSONCredential, createCustomToken, getAccessToken };
92 changes: 92 additions & 0 deletions src/lib/nodes/rtdb-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* 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;

await got.put(url, {
headers: { Authorization: `Bearer ${token?.access_token}` },
body: JSON.stringify(writeSizeLimit),
responseType: "json",
});

res.sendStatus(200);
} 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 };
20 changes: 19 additions & 1 deletion src/nodes/firebase-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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)
);
}

0 comments on commit a23fd4e

Please sign in to comment.