@mongoosejs/studio 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ For local dev, if your app runs on `localhost:3000`, Studio will be on `localhos
12
12
 
13
13
  By default, Mongoose Studio does **not** provide any authentication or authorization.
14
14
  You can use Mongoose Studio for free for local development, but we recommend [Mongoose Studio Pro](https://studio.mongoosejs.io/#pricing) for when you want to go into production.
15
+ When you omit an API key, Mongoose Studio only accepts localhost connections by default.
15
16
 
16
17
  First, `npm install @mongoosejs/studio`.
17
18
 
@@ -43,6 +44,14 @@ opts.googleGeminiAPIKey = process.env.GOOGLE_GEMINI_API_KEY;
43
44
  app.use('/studio', await studio('/studio/api', mongoose, opts));
44
45
  ```
45
46
 
47
+ Without an API key, you can allow access from additional IP addresses with `bindIp`, similar to MongoDB's `bindIp` option. `bindIp` may be a comma-separated string or an array of exact IP addresses. Set `bindIp` to `null` to allow unauthenticated connections from anywhere.
48
+
49
+ ```javascript
50
+ app.use('/studio', await studio('/studio/api', mongoose, {
51
+ bindIp: '127.0.0.1,192.168.0.10'
52
+ }));
53
+ ```
54
+
46
55
  ### Next.js
47
56
 
48
57
  First, add `withMongooseStudio` to your `next.config.js` file:
@@ -5,7 +5,7 @@ const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
5
5
  const evaluateFilter = require('../../helpers/evaluateFilter');
6
6
  const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
7
7
  const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
8
- const parseFieldsParam = require('../../helpers/parseFieldsParam');
8
+ const parseProjectionParam = require('../../helpers/parseProjectionParam');
9
9
  const authorize = require('../../authorize');
10
10
 
11
11
  const GetDocumentsParams = new Archetype({
@@ -32,7 +32,7 @@ const GetDocumentsParams = new Archetype({
32
32
  sortDirection: {
33
33
  $type: 'number'
34
34
  },
35
- fields: {
35
+ projectionInput: {
36
36
  $type: 'string'
37
37
  },
38
38
  roles: {
@@ -45,7 +45,7 @@ module.exports = ({ db }) => async function getDocuments(params) {
45
45
  const { roles } = params;
46
46
  await authorize('Model.getDocuments', roles);
47
47
 
48
- const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
48
+ const { model, limit, skip, sortKey, sortDirection, searchText, projectionInput } = params;
49
49
 
50
50
  const Model = db.models[model];
51
51
  if (Model == null) {
@@ -68,7 +68,10 @@ module.exports = ({ db }) => async function getDocuments(params) {
68
68
  }
69
69
 
70
70
  let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj);
71
- const projection = parseFieldsParam(fields);
71
+ const projection = parseProjectionParam({
72
+ projectionInput,
73
+ schemaPaths: Object.keys(Model.schema.paths)
74
+ });
72
75
  if (projection != null) {
73
76
  query = query.select(projection);
74
77
  }
@@ -5,7 +5,7 @@ const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
5
5
  const evaluateFilter = require('../../helpers/evaluateFilter');
6
6
  const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
7
7
  const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
8
- const parseFieldsParam = require('../../helpers/parseFieldsParam');
8
+ const parseProjectionParam = require('../../helpers/parseProjectionParam');
9
9
  const authorize = require('../../authorize');
10
10
 
11
11
  const GetDocumentsParams = new Archetype({
@@ -32,7 +32,7 @@ const GetDocumentsParams = new Archetype({
32
32
  sortDirection: {
33
33
  $type: 'number'
34
34
  },
35
- fields: {
35
+ projectionInput: {
36
36
  $type: 'string'
37
37
  },
38
38
  roles: {
@@ -45,7 +45,7 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
45
45
  const { roles } = params;
46
46
  await authorize('Model.getDocumentsStream', roles);
47
47
 
48
- const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
48
+ const { model, limit, skip, sortKey, sortDirection, searchText, projectionInput } = params;
49
49
 
50
50
  const Model = db.models[model];
51
51
  if (Model == null) {
@@ -68,7 +68,10 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
68
68
  }
69
69
 
70
70
  let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj).batchSize(1);
71
- const projection = parseFieldsParam(fields);
71
+ const projection = parseProjectionParam({
72
+ projectionInput,
73
+ schemaPaths: Object.keys(Model.schema.paths)
74
+ });
72
75
  if (projection != null) {
73
76
  query = query.select(projection);
74
77
  }
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = function isBindIPConnection(req, bindIp) {
4
+ if (bindIp == null) {
5
+ return true;
6
+ }
7
+ return bindIp.includes(req.ip || '');
8
+ };
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = function isLocalhostConnection(req) {
4
+ const remoteAddress = req.ip || '';
5
+ return remoteAddress === '127.0.0.1' ||
6
+ remoteAddress === '::1' ||
7
+ remoteAddress === '::ffff:127.0.0.1';
8
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ module.exports = function normalizeBindIPOption(bindIp) {
4
+ if (bindIp === null) {
5
+ return null;
6
+ }
7
+ if (bindIp === undefined) {
8
+ return ['localhost'];
9
+ }
10
+
11
+ const bindIps = Array.isArray(bindIp) ? bindIp : `${bindIp}`.split(',');
12
+ return bindIps.
13
+ flatMap(bindIp => `${bindIp}`.split(',')).
14
+ map(bindIp => bindIp.trim()).
15
+ filter(bindIp => bindIp.length > 0);
16
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse a raw projection input string into a Query.select()-compatible projection.
5
+ *
6
+ * Supports:
7
+ * - `name email`
8
+ * - `-password`
9
+ * - `+email`
10
+ * - `{ deletedAt: 1 }`
11
+ * - `{ password: 0 }`
12
+ *
13
+ * @param {Object} options
14
+ * @param {string|undefined} options.projectionInput
15
+ * @param {string[]} options.schemaPaths
16
+ * @returns {string|object|null}
17
+ */
18
+ function parseProjectionParam({ projectionInput, schemaPaths }) {
19
+ return parseProjectionInput(projectionInput, schemaPaths);
20
+ }
21
+
22
+ function parseProjectionInput(projectionInput, schemaPaths) {
23
+ if (projectionInput == null || typeof projectionInput !== 'string') {
24
+ return null;
25
+ }
26
+ const trimmed = projectionInput.trim();
27
+ if (!trimmed) {
28
+ return null;
29
+ }
30
+
31
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
32
+ return parseProjectionObjectNotation(trimmed, schemaPaths);
33
+ }
34
+
35
+ const tokens = normalizeProjectionTokens(trimmed);
36
+ if (tokens === null || tokens.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ const includeKeys = [];
41
+ const excludeKeys = [];
42
+
43
+ for (const rawToken of tokens) {
44
+ const token = rawToken.trim();
45
+ if (!token) {
46
+ continue;
47
+ }
48
+
49
+ const prefix = token[0];
50
+ if (prefix === '-') {
51
+ const path = token.slice(1).trim();
52
+ if (!path) {
53
+ return null;
54
+ }
55
+ excludeKeys.push(path);
56
+ } else if (prefix === '+') {
57
+ const path = token.slice(1).trim();
58
+ if (!path) {
59
+ return null;
60
+ }
61
+ includeKeys.push(path);
62
+ } else {
63
+ includeKeys.push(token);
64
+ }
65
+ }
66
+
67
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
68
+ const includeSet = new Set(includeKeys);
69
+ for (const path of excludeKeys) {
70
+ const ex = path;
71
+ for (const key of Array.from(includeSet)) {
72
+ if (key.toLowerCase() === ex.toLowerCase()) {
73
+ includeSet.delete(key);
74
+ }
75
+ }
76
+ }
77
+ if (includeSet.size === 0) {
78
+ return null;
79
+ }
80
+ return Array.from(includeSet).join(' ');
81
+ }
82
+
83
+ if (excludeKeys.length > 0) {
84
+ return excludeKeys.map(path => `-${path}`).join(' ');
85
+ }
86
+
87
+ return includeKeys.join(' ');
88
+ }
89
+
90
+ function normalizeProjectionTokens(trimmed) {
91
+ const rawTokens = trimmed.split(/[,\s]+/).filter(Boolean);
92
+ const tokens = [];
93
+ for (let i = 0; i < rawTokens.length; i++) {
94
+ const token = rawTokens[i];
95
+ if (token === '-' || token === '+') {
96
+ if (i + 1 >= rawTokens.length) {
97
+ return null;
98
+ }
99
+ tokens.push(token + rawTokens[++i]);
100
+ } else {
101
+ tokens.push(token);
102
+ }
103
+ }
104
+ return tokens;
105
+ }
106
+
107
+ function parseProjectionObjectNotation(trimmed, schemaPaths) {
108
+ const body = trimmed.slice(1, -1).trim();
109
+ if (!body) {
110
+ return null;
111
+ }
112
+
113
+ const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
114
+ const includeKeys = [];
115
+ const excludeKeys = [];
116
+ let matchedChars = '';
117
+ let match;
118
+
119
+ while ((match = pairRe.exec(body)) !== null) {
120
+ matchedChars += match[0];
121
+ const key = (match[1] || match[2] || match[3] || '').trim();
122
+ const rawValue = (match[4] || '').trim();
123
+ if (!key || !rawValue) {
124
+ return null;
125
+ }
126
+ if (key === '__proto__' || key === 'constructor') {
127
+ throw new Error(`Invalid key: ${key}`);
128
+ }
129
+
130
+ const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
131
+ const isInclude = valueLower === '1' || valueLower === 'true';
132
+ const isExclude = valueLower === '0' || valueLower === 'false';
133
+ if (!isInclude && !isExclude) {
134
+ return null;
135
+ }
136
+ if (isInclude) {
137
+ includeKeys.push(key);
138
+ } else {
139
+ excludeKeys.push(key);
140
+ }
141
+ }
142
+
143
+ const normalizedBody = body.replace(/\s+/g, '');
144
+ const normalizedMatched = matchedChars.replace(/\s+/g, '').replace(/^,/, '');
145
+ if (!normalizedMatched || normalizedMatched !== normalizedBody) {
146
+ return null;
147
+ }
148
+
149
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
150
+ const includeSet = new Set(includeKeys);
151
+ for (const path of excludeKeys) {
152
+ const ex = path;
153
+ for (const key of Array.from(includeSet)) {
154
+ if (key.toLowerCase() === ex.toLowerCase()) {
155
+ includeSet.delete(key);
156
+ }
157
+ }
158
+ }
159
+ if (includeSet.size === 0) {
160
+ return null;
161
+ }
162
+ return Array.from(includeSet).join(' ');
163
+ }
164
+
165
+ if (excludeKeys.length > 0) {
166
+ const projection = {};
167
+ for (const path of excludeKeys) {
168
+ projection[path] = 0;
169
+ }
170
+ return Object.keys(projection).length > 0 ? projection : null;
171
+ }
172
+
173
+ const projection = {};
174
+ for (const path of includeKeys) {
175
+ projection[path] = 1;
176
+ }
177
+ return Object.keys(projection).length > 0 ? projection : null;
178
+ }
179
+
180
+ module.exports = parseProjectionParam;
package/express.js CHANGED
@@ -3,12 +3,17 @@
3
3
  const Backend = require('./backend');
4
4
  const express = require('express');
5
5
  const frontend = require('./frontend');
6
+ const isBindIPConnection = require('./backend/helpers/isBindIPConnection');
7
+ const isLocalhostConnection = require('./backend/helpers/isLocalhostConnection');
8
+ const normalizeBindIPOption = require('./backend/helpers/normalizeBindIPOption');
6
9
  const { toRoute, objectRouter } = require('extrovert');
7
10
  const { defaultMothershipURL } = require('./constants');
8
11
 
9
12
  module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options) {
10
13
  const router = express.Router();
11
14
  options = options ? { changeStream: true, ...options } : { changeStream: true };
15
+ const hasBindIpOption = Object.prototype.hasOwnProperty.call(options, 'bindIp');
16
+ const bindIp = normalizeBindIPOption(options.bindIp);
12
17
 
13
18
  const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
14
19
  let workspace = null;
@@ -32,6 +37,17 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
32
37
  .then(res => res.json()));
33
38
  }
34
39
 
40
+ if (!workspace && bindIp !== null) {
41
+ router.use((req, res, next) => {
42
+ const allowed = hasBindIpOption ? isBindIPConnection(req, bindIp) : isLocalhostConnection(req);
43
+ if (!allowed) {
44
+ return res.status(403).json({ message: 'Mongoose Studio without an API key only accepts localhost or configured bindIp connections' });
45
+ }
46
+
47
+ next();
48
+ });
49
+ }
50
+
35
51
  apiUrl = apiUrl || 'api';
36
52
  const backend = Backend(conn, options.studioConnection, options);
37
53
  delete backend.services;