Skip to content

Commit

Permalink
Merge branch 'master' into ss-fix-1454
Browse files Browse the repository at this point in the history
  • Loading branch information
samtstern committed Jul 8, 2019
2 parents a0841e4 + 838f1a2 commit 9b5dfe6
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 47 deletions.
5 changes: 4 additions & 1 deletion README.md
Expand Up @@ -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');
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion src/emulator/controller.ts
Expand Up @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -102,6 +103,8 @@ export async function startAll(options: any): Promise<void> {
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(",");
Expand Down Expand Up @@ -130,6 +133,7 @@ export async function startAll(options: any): Promise<void> {
const args: FirestoreEmulatorArgs = {
host: firestoreAddr.host,
port: firestoreAddr.port,
projectId,
auto_download: true,
};

Expand Down
84 changes: 84 additions & 0 deletions 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;
Expand All @@ -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<void> {
Expand All @@ -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);
}

Expand All @@ -32,6 +63,10 @@ export class FirestoreEmulator implements EmulatorInstance {
}

async stop(): Promise<void> {
if (this.rulesWatcher) {
this.rulesWatcher.close();
}

return javaEmulators.stop(Emulators.FIRESTORE);
}

Expand All @@ -48,4 +83,53 @@ export class FirestoreEmulator implements EmulatorInstance {
getName(): Emulators {
return Emulators.FIRESTORE;
}

private updateRules(content: string): Promise<Issue[]> {
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}`;
}
}
23 changes: 23 additions & 0 deletions src/emulator/types.ts
Expand Up @@ -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;
}
68 changes: 23 additions & 45 deletions src/firestore/delete.js
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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: {
Expand Down Expand Up @@ -234,13 +214,11 @@ FirestoreDelete.prototype._docDescendantsQuery = function(allDescendants, batchS
* @return {Promise<object[]>} 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);
}

Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 9b5dfe6

Please sign in to comment.