diff --git a/README.md b/README.md index b28b7086130..ff0dc292d76 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ will immediately revoke access for the specified token. ## Using as a Module -The Firebase CLI can also be used programmatically as a standard Node module. This can only be done on your machine, and cannot be done within Cloud Functions. Each command is exposed as a function that takes an options object and returns a Promise. For example: +The Firebase CLI can also be used programmatically as a standard Node module. Each command is exposed as a function that takes an options object and returns a Promise. For example: ```js var client = require('firebase-tools'); @@ -148,6 +148,9 @@ client.deploy({ }); ``` +Note: when used in a limited environment like Cloud Functions, not all `firebase-tools` commands will work programatically +because they require access to a local filesystem. + [travis-ci]: https://travis-ci.org/firebase/firebase-tools [coveralls]: https://coveralls.io/r/firebase/firebase-tools [npm]: https://www.npmjs.com/package/firebase-tools diff --git a/changelog.txt b/changelog.txt index d584e51d207..8f1cc08c9e6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -3,4 +3,5 @@ * Improve error message when `firebase serve` can't acquire the right port. * Allow running the Firestore and RTDB emulators without a configuration. * Allow the Firestore emulator to give more information about invalid rulesets. +* Hot reload firestore rules on change. * Fixes a bug where `admin.firestore()` and `app.firestore()` behaved differently. \ No newline at end of file diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 7a8f058b703..c49b45c1910 100644 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -2,6 +2,7 @@ import * as _ from "lodash"; import * as clc from "cli-color"; import * as fs from "fs"; import * as pf from "portfinder"; +import * as path from "path"; import * as utils from "../utils"; import * as track from "../track"; @@ -14,7 +15,7 @@ import { DatabaseEmulator } from "../emulator/databaseEmulator"; import { FirestoreEmulator, FirestoreEmulatorArgs } from "../emulator/firestoreEmulator"; import { HostingEmulator } from "../emulator/hostingEmulator"; import * as FirebaseError from "../error"; -import * as path from "path"; +import * as getProjectId from "../getProjectId"; export const VALID_EMULATOR_STRINGS: string[] = ALL_EMULATORS; @@ -102,6 +103,8 @@ export async function startAll(options: any): Promise { const targets: string[] = filterTargets(options, VALID_EMULATOR_STRINGS); options.targets = targets; + const projectId: string | undefined = getProjectId(options, true); + utils.logBullet(`Starting emulators: ${JSON.stringify(targets)}`); if (options.only) { const requested: string[] = options.only.split(","); @@ -130,6 +133,7 @@ export async function startAll(options: any): Promise { const args: FirestoreEmulatorArgs = { host: firestoreAddr.host, port: firestoreAddr.port, + projectId, auto_download: true, }; diff --git a/src/emulator/firestoreEmulator.ts b/src/emulator/firestoreEmulator.ts index 8c7e21a16d2..795ebe72f2d 100644 --- a/src/emulator/firestoreEmulator.ts +++ b/src/emulator/firestoreEmulator.ts @@ -1,11 +1,21 @@ +import * as _ from "lodash"; +import * as chokidar from "chokidar"; +import * as fs from "fs"; +import * as request from "request"; +import * as clc from "cli-color"; +import * as path from "path"; + +import * as utils from "../utils"; import * as javaEmulators from "../serve/javaEmulators"; import { EmulatorInfo, EmulatorInstance, Emulators } from "../emulator/types"; import { EmulatorRegistry } from "./registry"; import { Constants } from "./constants"; +import { Issue } from "./types"; export interface FirestoreEmulatorArgs { port?: number; host?: string; + projectId?: string; rules?: string; functions_emulator?: string; auto_download?: boolean; @@ -15,6 +25,8 @@ export class FirestoreEmulator implements EmulatorInstance { static FIRESTORE_EMULATOR_ENV = "FIRESTORE_EMULATOR_HOST"; static FIRESTORE_EMULATOR_ENV_ALT = "FIREBASE_FIRESTORE_EMULATOR_ADDRESS"; + rulesWatcher?: chokidar.FSWatcher; + constructor(private args: FirestoreEmulatorArgs) {} async start(): Promise { @@ -23,6 +35,25 @@ export class FirestoreEmulator implements EmulatorInstance { this.args.functions_emulator = `localhost:${functionsPort}`; } + if (this.args.rules && this.args.projectId) { + const rulesPath = this.args.rules; + this.rulesWatcher = chokidar.watch(rulesPath, { persistent: true, ignoreInitial: true }); + this.rulesWatcher.on("change", async (event, stats) => { + const newContent = fs.readFileSync(rulesPath).toString(); + + utils.logLabeledBullet("firestore", "Change detected, updating rules..."); + const issues = await this.updateRules(newContent); + if (issues && issues.length > 0) { + for (const issue of issues) { + utils.logWarning(this.prettyPrintRulesIssue(rulesPath, issue)); + } + utils.logWarning("Failed to update rules"); + } else { + utils.logLabeledSuccess("firestore", "Rules updated."); + } + }); + } + return javaEmulators.start(Emulators.FIRESTORE, this.args); } @@ -32,6 +63,10 @@ export class FirestoreEmulator implements EmulatorInstance { } async stop(): Promise { + if (this.rulesWatcher) { + this.rulesWatcher.close(); + } + return javaEmulators.stop(Emulators.FIRESTORE); } @@ -48,4 +83,53 @@ export class FirestoreEmulator implements EmulatorInstance { getName(): Emulators { return Emulators.FIRESTORE; } + + private updateRules(content: string): Promise { + const projectId = this.args.projectId; + + const { host, port } = this.getInfo(); + const url = `http://${host}:${port}/emulator/v1/projects/${projectId}:securityRules`; + const body = { + // Invalid rulesets will still result in a 200 response but with more information + ignore_errors: true, + rules: { + files: [ + { + name: "security.rules", + content, + }, + ], + }, + }; + + return new Promise((resolve, reject) => { + request.put(url, { json: body }, (err, res, resBody) => { + if (err) { + reject(err); + return; + } + + const rulesValid = res.statusCode === 200 && !resBody.issues; + if (!rulesValid) { + const issues = resBody.issues as Issue[]; + resolve(issues); + } + + resolve([]); + }); + }); + } + + /** + * Create a colorized and human-readable string describing a Rules validation error. + * Ex: firestore:21:4 - ERROR expected 'if' + */ + private prettyPrintRulesIssue(filePath: string, issue: Issue): string { + const relativePath = path.relative(process.cwd(), filePath); + const line = issue.sourcePosition.line || 0; + const col = issue.sourcePosition.column || 0; + return `${clc.cyan(relativePath)}:${clc.yellow(line)}:${clc.yellow(col)} - ${clc.red( + issue.severity + )} ${issue.description}`; + } } diff --git a/src/emulator/types.ts b/src/emulator/types.ts index 122288446cc..f128493863d 100644 --- a/src/emulator/types.ts +++ b/src/emulator/types.ts @@ -150,3 +150,26 @@ export class EmulatorLog { ); } } + +/** + * google.firebase.rules.v1.Issue + */ +export interface Issue { + sourcePosition: SourcePosition; + description: string; + severity: Severity; +} + +export enum Severity { + SEVERITY_UNSPECIFIED = 0, + DEPRECATION = 1, + WARNING = 2, + ERROR = 3, +} + +export interface SourcePosition { + fileName: string; + fileIndex: number; + line: number; + column: number; +} diff --git a/src/firestore/delete.js b/src/firestore/delete.js index fd62e1fd4b5..7518ab451f6 100644 --- a/src/firestore/delete.js +++ b/src/firestore/delete.js @@ -26,21 +26,32 @@ var MIN_ID = "__id-9223372036854775808__"; */ function FirestoreDelete(project, path, options) { this.project = project; - this.path = path; + this.path = path || ""; this.recursive = Boolean(options.recursive); this.shallow = Boolean(options.shallow); this.allCollections = Boolean(options.allCollections); // Remove any leading or trailing slashes from the path - if (this.path) { - this.path = this.path.replace(/(^\/+|\/+$)/g, ""); - } - - this.isDocumentPath = this._isDocumentPath(this.path); - this.isCollectionPath = this._isCollectionPath(this.path); + this.path = this.path.replace(/(^\/+|\/+$)/g, ""); this.allDescendants = this.recursive; - this.parent = "projects/" + project + "/databases/(default)/documents"; + this.root = "projects/" + project + "/databases/(default)/documents"; + + var segments = this.path.split("/"); + this.isDocumentPath = segments.length % 2 === 0; + this.isCollectionPath = !this.isDocumentPath; + + // this.parent is the closest ancestor document to the location we're deleting. + // If we are deleting a document, this.parent is the path of that document. + // If we are deleting a collection, this.parent is the path of the document + // containing that collection (or the database root, if it is a root collection). + this.parent = this.root; + if (this.isCollectionPath) { + segments.pop(); + } + if (segments.length > 0) { + this.parent += "/" + segments.join("/"); + } // When --all-collections is passed any other flags or arguments are ignored if (!options.allCollections) { @@ -75,37 +86,6 @@ FirestoreDelete.prototype._validateOptions = function() { } }; -/** - * Determine if a path points to a document. - * - * @param {string} path a path to a Firestore document or collection. - * @return {boolean} true if the path points to a document, false - * if it points to a collection. - */ -FirestoreDelete.prototype._isDocumentPath = function(path) { - if (!path) { - return false; - } - - var pieces = path.split("/"); - return pieces.length % 2 === 0; -}; - -/** - * Determine if a path points to a collection. - * - * @param {string} path a path to a Firestore document or collection. - * @return {boolean} true if the path points to a collection, false - * if it points to a document. - */ -FirestoreDelete.prototype._isCollectionPath = function(path) { - if (!path) { - return false; - } - - return !this._isDocumentPath(path); -}; - /** * Construct a StructuredQuery to find descendant documents of a collection. * @@ -124,8 +104,8 @@ FirestoreDelete.prototype._collectionDescendantsQuery = function( ) { var nullChar = String.fromCharCode(0); - var startAt = this.parent + "/" + this.path + "/" + MIN_ID; - var endAt = this.parent + "/" + this.path + nullChar + "/" + MIN_ID; + var startAt = this.root + "/" + this.path + "/" + MIN_ID; + var endAt = this.root + "/" + this.path + nullChar + "/" + MIN_ID; var where = { compositeFilter: { @@ -234,13 +214,11 @@ FirestoreDelete.prototype._docDescendantsQuery = function(allDescendants, batchS * @return {Promise} a promise for an array of documents. */ FirestoreDelete.prototype._getDescendantBatch = function(allDescendants, batchSize, startAfter) { - var url; + var url = this.parent + ":runQuery"; var body; if (this.isDocumentPath) { - url = this.parent + "/" + this.path + ":runQuery"; body = this._docDescendantsQuery(allDescendants, batchSize, startAfter); } else { - url = this.parent + ":runQuery"; body = this._collectionDescendantsQuery(allDescendants, batchSize, startAfter); } @@ -401,7 +379,7 @@ FirestoreDelete.prototype._deletePath = function() { var self = this; var initialDelete; if (this.isDocumentPath) { - var doc = { name: this.parent + "/" + this.path }; + var doc = { name: this.root + "/" + this.path }; initialDelete = firestore.deleteDocument(doc).catch(function(err) { logger.debug("deletePath:initialDelete:error", err); if (self.allDescendants) {