@naturalcycles/backend-lib 2.60.1 → 2.61.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ # [2.61.0](https://github.com/NaturalCycles/backend-lib/compare/v2.60.4...v2.61.0) (2021-10-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * clarify startServer cfg, use process.uptime() ([9185603](https://github.com/NaturalCycles/backend-lib/commit/9185603bec23f5bcccfe764125d8af1efcfc3c11))
7
+
8
+ ## [2.60.4](https://github.com/NaturalCycles/backend-lib/compare/v2.60.3...v2.60.4) (2021-10-21)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * deps, use process.uptime in statusHandler ([8d3389d](https://github.com/NaturalCycles/backend-lib/commit/8d3389d8908954af9a375a6d7fb95aefc1b08e5f))
14
+
15
+ ## [2.60.3](https://github.com/NaturalCycles/backend-lib/compare/v2.60.2...v2.60.3) (2021-10-15)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * deps (firebase-admin@10) ([50fe05b](https://github.com/NaturalCycles/backend-lib/commit/50fe05be92cb4345b809096d3b45b1b099bc2ea5))
21
+
22
+ ## [2.60.2](https://github.com/NaturalCycles/backend-lib/compare/v2.60.1...v2.60.2) (2021-10-09)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * login.html by inroducing an `/admin/login` endpoint ([6e16d0f](https://github.com/NaturalCycles/backend-lib/commit/6e16d0fd8f26d35dd562687a29a120b32d9f2717))
28
+
1
29
  ## [2.60.1](https://github.com/NaturalCycles/backend-lib/compare/v2.60.0...v2.60.1) (2021-10-04)
2
30
 
3
31
 
@@ -12,6 +12,11 @@ export interface RequireAdminCfg {
12
12
  */
13
13
  apiHost?: string;
14
14
  urlStartsWith?: string;
15
+ /**
16
+ * Defaults to `true`.
17
+ * Set to `false` to debug login issues.
18
+ */
19
+ autoLogin?: boolean;
15
20
  }
16
21
  export declare type AdminMiddleware = (reqPermissions?: string[], cfg?: RequireAdminCfg) => RequestHandler;
17
22
  export declare function createAdminMiddleware(adminService: BaseAdminService, cfgDefaults?: RequireAdminCfg): AdminMiddleware;
@@ -20,7 +20,7 @@ exports.createAdminMiddleware = createAdminMiddleware;
20
20
  * Otherwise will just pass.
21
21
  */
22
22
  function requireAdminPermissions(adminService, reqPermissions = [], cfg = {}) {
23
- const { loginHtmlPath = '/login.html', urlStartsWith, apiHost } = cfg;
23
+ const { loginHtmlPath = '/login.html', urlStartsWith, apiHost, autoLogin = true } = cfg;
24
24
  return async (req, res, next) => {
25
25
  if (urlStartsWith && !req.url.startsWith(urlStartsWith))
26
26
  return next();
@@ -31,7 +31,7 @@ function requireAdminPermissions(adminService, reqPermissions = [], cfg = {}) {
31
31
  catch (err) {
32
32
  if (err instanceof js_lib_1.HttpError && err.data.adminAuthRequired) {
33
33
  // Redirect to login.html
34
- const href = `${loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}${apiHost ? '&apiHost=' + apiHost : ''}`;
34
+ const href = `${loginHtmlPath}?${autoLogin ? 'autoLogin=1&' : ''}returnUrl=\${encodeURIComponent(location.href)}${apiHost ? '&apiHost=' + apiHost : ''}`;
35
35
  res.status(401).send(getLoginHtmlRedirect(href));
36
36
  }
37
37
  else {
@@ -1,4 +1,4 @@
1
- import { Request } from 'express';
1
+ import { Request, RequestHandler } from 'express';
2
2
  import type * as FirebaseAdmin from 'firebase-admin';
3
3
  export interface AdminServiceCfg {
4
4
  /**
@@ -52,4 +52,14 @@ export declare class BaseAdminService {
52
52
  requirePermissions(req: Request, reqPermissions?: string[], meta?: Record<string, any>, andComparison?: boolean): Promise<AdminInfo>;
53
53
  hasPermission(req: Request, reqPermission: string, meta?: Record<string, any>): Promise<boolean>;
54
54
  requirePermission(req: Request, reqPermission: string, meta?: Record<string, any>): Promise<AdminInfo>;
55
+ /**
56
+ * Install it on POST /admin/login url
57
+ *
58
+ * It takes a POST request with `Authentication` header, that contains `accessToken` from Firebase Auth.
59
+ * Backend doesn't validate the token, but only does `setCookie` (secure, httpOnly), returns http 204 (ok, empty response).
60
+ * Frontend (login.html page) will then proceed with redirecting to `returnUrl`.
61
+ *
62
+ * Same endpoint is used to logout, but the `Authentication` header should contain `logout` magic string.
63
+ */
64
+ getFirebaseAuthLoginHandler(): RequestHandler;
55
65
  }
@@ -153,5 +153,39 @@ class BaseAdminService {
153
153
  async requirePermission(req, reqPermission, meta) {
154
154
  return await this.requirePermissions(req, [reqPermission], meta);
155
155
  }
156
+ /**
157
+ * Install it on POST /admin/login url
158
+ *
159
+ * It takes a POST request with `Authentication` header, that contains `accessToken` from Firebase Auth.
160
+ * Backend doesn't validate the token, but only does `setCookie` (secure, httpOnly), returns http 204 (ok, empty response).
161
+ * Frontend (login.html page) will then proceed with redirecting to `returnUrl`.
162
+ *
163
+ * Same endpoint is used to logout, but the `Authentication` header should contain `logout` magic string.
164
+ */
165
+ getFirebaseAuthLoginHandler() {
166
+ return async (req, res) => {
167
+ const token = req.header('authentication');
168
+ (0, js_lib_1._assert)(token, `401 Unauthenticated`, {
169
+ userFriendly: true,
170
+ httpStatusCode: 401,
171
+ });
172
+ let maxAge = 1000 * 60 * 60 * 24 * 30; // 30 days
173
+ // Special case
174
+ if (token === 'logout') {
175
+ // delete the cookie
176
+ maxAge = 0;
177
+ }
178
+ res
179
+ .cookie(this.cfg.adminTokenKey, token, {
180
+ maxAge,
181
+ sameSite: 'lax',
182
+ // comment these 2 lines to debug on localhost
183
+ httpOnly: true,
184
+ secure: true,
185
+ })
186
+ .status(204)
187
+ .end();
188
+ };
189
+ }
156
190
  }
157
191
  exports.BaseAdminService = BaseAdminService;
@@ -6,11 +6,12 @@
6
6
  <meta charset="utf-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
8
8
 
9
- <script src="https://www.gstatic.com/firebasejs/6.3.5/firebase-app.js"></script>
10
- <script src="https://www.gstatic.com/firebasejs/6.3.5/firebase-auth.js"></script>
11
- <script src="https://unpkg.com/vue@2.6.10/dist/vue.min.js"></script>
12
-
13
- <link href="https://unpkg.com/bootstrap@4.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
9
+ <!-- CSS only -->
10
+ <link
11
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.2/dist/css/bootstrap.min.css"
12
+ rel="stylesheet"
13
+ crossorigin="anonymous"
14
+ />
14
15
  </head>
15
16
  <body>
16
17
  <div id="app" style="padding: 40px 50px">
@@ -28,33 +29,54 @@
28
29
  </div>
29
30
  </div>
30
31
 
31
- <script>
32
+ <script type="module">
33
+ import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3.2.20/dist/vue.esm-browser.prod.js'
34
+ import { initializeApp } from 'https://www.gstatic.com/firebasejs/9.1.2/firebase-app.js'
35
+ import {
36
+ getAuth,
37
+ GoogleAuthProvider,
38
+ onAuthStateChanged,
39
+ signInWithRedirect,
40
+ } from 'https://www.gstatic.com/firebasejs/9.1.2/firebase-auth.js'
41
+
32
42
  const apiKey = '<%= firebaseApiKey %>'
33
43
  const authDomain = '<%= firebaseAuthDomain %>'
34
- const authProvider = '<%= firebaseAuthProvider %>'
44
+ // const authProvider = '<%= firebaseAuthProvider %>'
35
45
 
36
46
  if (!apiKey || !authDomain) {
37
47
  alert(`Error: 'apiKey' or 'authDomain' is missing!`)
38
48
  }
39
49
 
40
- const qs = parseQuery(location.search)
41
- const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
42
- console.log(qs)
50
+ // Initialize Firebase
51
+ initializeApp({
52
+ apiKey,
53
+ authDomain,
54
+ })
43
55
 
44
- const provider = new firebase.auth[authProvider]()
56
+ const auth = getAuth()
57
+ const provider = new GoogleAuthProvider()
45
58
 
46
- const app = new Vue({
47
- el: '#app',
59
+ onAuthStateChanged(auth, user => {
60
+ // console.log('onAuthStateChanged, user: ', JSON.stringify(user, null, 2))
61
+ console.log('onAuthStateChanged, user: ', user)
62
+ onUser(user)
63
+ })
48
64
 
49
- data: {
50
- loading: 'Loading...',
51
- user: undefined,
52
- },
65
+ const qs = Object.fromEntries(new URLSearchParams(location.search))
66
+ const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
67
+ console.log(qs)
53
68
 
69
+ const app = createApp({
70
+ data() {
71
+ return {
72
+ loading: 'Loading...',
73
+ user: undefined,
74
+ }
75
+ },
54
76
  methods: {
55
77
  login: async function () {
56
78
  try {
57
- await firebase.auth().signInWithRedirect(provider)
79
+ await signInWithRedirect(auth, provider)
58
80
  } catch (err) {
59
81
  logError(err)
60
82
  }
@@ -62,7 +84,9 @@
62
84
 
63
85
  logout: async function () {
64
86
  try {
65
- await firebase.auth().signOut()
87
+ await auth.signOut()
88
+
89
+ await postToken('logout') // magic string
66
90
 
67
91
  if (logout && returnUrl) {
68
92
  alert('Logged out, redurecting back...')
@@ -73,20 +97,7 @@
73
97
  }
74
98
  },
75
99
  },
76
- })
77
-
78
- // Initialize Firebase
79
- const config = {
80
- apiKey,
81
- authDomain,
82
- }
83
- firebase.initializeApp(config)
84
-
85
- firebase.auth().onAuthStateChanged(user => {
86
- // console.log('onAuthStateChanged, user: ', JSON.stringify(user, null, 2))
87
- console.log('onAuthStateChanged, user: ', user)
88
- onUser(user)
89
- })
100
+ }).mount('#app')
90
101
 
91
102
  if (logout) app.logout()
92
103
 
@@ -100,13 +111,16 @@
100
111
  if (!user) {
101
112
  if (autoLogin) app.login()
102
113
  } else {
103
- const token = await firebase.auth().currentUser.getIdToken(true)
114
+ const token = await auth.currentUser.getIdToken()
115
+
104
116
  // alert('idToken')
105
117
  // console.log(idToken)
106
118
  app.user = Object.assign({}, app.user, {
107
119
  token,
108
120
  })
109
121
 
122
+ await postToken(token)
123
+
110
124
  // Redirect if needed
111
125
  if (returnUrl) {
112
126
  // alert(`Logged in as ${app.user.email}, redirecting back...`)
@@ -118,20 +132,19 @@
118
132
  }
119
133
  }
120
134
 
121
- function parseQuery(queryString) {
122
- const query = {}
123
- const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&')
124
- for (let i = 0; i < pairs.length; i++) {
125
- const pair = pairs[i].split('=')
126
- query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '')
127
- }
128
- return query
129
- }
130
-
131
135
  function logError(err) {
132
136
  console.error(err)
133
137
  alert('Error\n ' + JSON.stringify(err, null, 2))
134
138
  }
139
+
140
+ async function postToken(token) {
141
+ await fetch(`/admin/login`, {
142
+ method: 'post',
143
+ headers: {
144
+ Authentication: token,
145
+ },
146
+ })
147
+ }
135
148
  </script>
136
149
  </body>
137
150
  </html>
@@ -1,5 +1,5 @@
1
- import { JsonSchemaRootObject } from '@naturalcycles/js-lib';
2
- import { BaseCommonDB, CommonDB, CommonDBOptions, CommonDBSaveOptions, CommonDBStreamOptions, DBQuery, ObjectWithId, RunQueryResult } from '@naturalcycles/db-lib';
1
+ import { JsonSchemaRootObject, ObjectWithId } from '@naturalcycles/js-lib';
2
+ import { BaseCommonDB, CommonDB, CommonDBOptions, CommonDBSaveOptions, CommonDBStreamOptions, DBQuery, RunQueryResult } from '@naturalcycles/db-lib';
3
3
  import { GetGotOptions, ReadableTyped } from '@naturalcycles/nodejs-lib';
4
4
  export interface HttpDBCfg extends GetGotOptions {
5
5
  prefixUrl: string;
@@ -1,4 +1,5 @@
1
- import { CommonDB, CommonDBOptions, CommonDBSaveOptions, DBQuery, ObjectWithId } from '@naturalcycles/db-lib';
1
+ import { CommonDB, CommonDBOptions, CommonDBSaveOptions, DBQuery } from '@naturalcycles/db-lib';
2
+ import { ObjectWithId } from '@naturalcycles/js-lib';
2
3
  import { Router } from 'express';
3
4
  export interface GetByIdsInput {
4
5
  table: string;
package/dist/index.d.ts CHANGED
@@ -32,13 +32,3 @@ import { BackendServer, startServer } from './server/startServer';
32
32
  import { StartServerCfg, StartServerData } from './server/startServer.model';
33
33
  export type { MethodOverrideCfg, SentrySharedServiceCfg, RequestHandlerWithPath, RequestHandlerCfg, DefaultAppCfg, StartServerCfg, StartServerData, EnvSharedServiceCfg, BaseEnv, AdminMiddleware, AdminServiceCfg, AdminInfo, RequireAdminCfg, SecureHeaderMiddlewareCfg, BodyParserTimeoutCfg, RequestTimeoutCfg, SimpleRequestLoggerCfg, ReqValidationOptions, };
34
34
  export { BackendServer, SentrySharedService, EnvSharedService, reqValidation, notFoundHandler, genericErrorHandler, methodOverride, sentryErrorHandler, createDefaultApp, startServer, catchWrapper, getDefaultRouter, isGAE, statusHandler, statusHandlerData, okHandler, getDeployInfo, onFinished, respondWithError, logRequest, FirebaseSharedService, createAdminMiddleware, BaseAdminService, loginHtml, createSecureHeaderMiddleware, bodyParserTimeout, clearBodyParserTimeout, requestTimeout, simpleRequestLogger, coloredHttpCode, getRequestContextProperty, setRequestContextProperty, requestContextMiddleware, requestIdMiddleware, REQUEST_ID_KEY, validateBody, validateParams, validateQuery, };
35
- declare global {
36
- namespace NodeJS {
37
- interface ProcessEnv {
38
- PORT?: string;
39
- GAE_APPLICATION?: string;
40
- GAE_SERVICE?: string;
41
- GAE_VERSION?: string;
42
- }
43
- }
44
- }
@@ -11,6 +11,7 @@ function reqValidation(reqProperty, schema, opt = {}) {
11
11
  if (opt.redactPaths) {
12
12
  redact(opt.redactPaths, req[reqProperty], error);
13
13
  error.data.joiValidationErrorItems.length = 0; // clears the array
14
+ delete error.data.annotation;
14
15
  }
15
16
  return next(new js_lib_1.HttpError(error.message, {
16
17
  httpStatusCode: 400,
@@ -1,8 +1,3 @@
1
1
  import { RequestHandler } from 'express';
2
- /**
3
- * @returns unix timestamp in millis
4
- */
5
- declare type ServerStartedCallback = () => number | undefined;
6
- export declare function statusHandler(serverStartedCallback?: ServerStartedCallback, projectDir?: string, extra?: any): RequestHandler;
7
- export declare function statusHandlerData(serverStartedCallback?: ServerStartedCallback, projectDir?: string, extra?: any): Record<string, any>;
8
- export {};
2
+ export declare function statusHandler(projectDir?: string, extra?: any): RequestHandler;
3
+ export declare function statusHandlerData(projectDir?: string, extra?: any): Record<string, any>;
@@ -5,29 +5,28 @@ const js_lib_1 = require("@naturalcycles/js-lib");
5
5
  const nodejs_lib_1 = require("@naturalcycles/nodejs-lib");
6
6
  const time_lib_1 = require("@naturalcycles/time-lib");
7
7
  const deployInfo_util_1 = require("../deployInfo.util");
8
- const now = Date.now();
9
- const defaultServerStartedCallback = () => now;
10
8
  const { versions } = process;
11
- function statusHandler(serverStartedCallback, projectDir, extra) {
9
+ const { GAE_APPLICATION, GAE_SERVICE, GAE_VERSION } = process.env;
10
+ function statusHandler(projectDir, extra) {
12
11
  return async (req, res) => {
13
- res.json(statusHandlerData(serverStartedCallback, projectDir, extra));
12
+ res.json(statusHandlerData(projectDir, extra));
14
13
  };
15
14
  }
16
15
  exports.statusHandler = statusHandler;
17
- function statusHandlerData(serverStartedCallback = defaultServerStartedCallback, projectDir = process.cwd(), extra) {
16
+ function statusHandlerData(projectDir = process.cwd(), extra) {
18
17
  const { APP_ENV } = process.env;
19
18
  const { gitRev, gitBranch, prod, ts } = (0, deployInfo_util_1.getDeployInfo)(projectDir);
20
19
  const deployBuildTimeUTC = time_lib_1.dayjs.unix(ts).toPretty();
21
20
  const buildInfo = [time_lib_1.dayjs.unix(ts).toCompactTime(), gitBranch, gitRev].filter(Boolean).join('_');
22
21
  return (0, js_lib_1._filterFalsyValues)({
23
- started: getStartedStr(serverStartedCallback()),
22
+ started: getStartedStr(),
24
23
  deployBuildTimeUTC,
25
24
  APP_ENV,
26
25
  prod,
27
26
  buildInfo,
28
- GAE_APPLICATION: process.env.GAE_APPLICATION,
29
- GAE_SERVICE: process.env.GAE_SERVICE,
30
- GAE_VERSION: process.env.GAE_VERSION,
27
+ GAE_APPLICATION,
28
+ GAE_SERVICE,
29
+ GAE_VERSION,
31
30
  mem: (0, nodejs_lib_1.memoryUsageFull)(),
32
31
  cpuAvg: nodejs_lib_1.processSharedUtil.cpuAvg(),
33
32
  // resourceUsage: process.resourceUsage?.(),
@@ -36,9 +35,8 @@ function statusHandlerData(serverStartedCallback = defaultServerStartedCallback,
36
35
  });
37
36
  }
38
37
  exports.statusHandlerData = statusHandlerData;
39
- function getStartedStr(serverStarted) {
40
- if (!serverStarted)
41
- return 'not started yet';
38
+ function getStartedStr() {
39
+ const serverStarted = time_lib_1.dayjs.utc().subtract(process.uptime(), 's');
42
40
  const s1 = (0, time_lib_1.dayjs)(serverStarted).toPretty();
43
41
  const s2 = (0, time_lib_1.dayjs)(serverStarted).fromNow();
44
42
  return `${s1} UTC (${s2})`;
@@ -10,7 +10,7 @@ class BackendServer {
10
10
  this.cfg = cfg;
11
11
  }
12
12
  async start() {
13
- const { bootstrapStartedAt = Date.now(), port: cfgPort, expressApp } = this.cfg;
13
+ const { port: cfgPort, expressApp } = this.cfg;
14
14
  // 1. Register error handlers, etc.
15
15
  process.on('uncaughtException', err => {
16
16
  log_1.log.error('uncaughtException:', err);
@@ -22,7 +22,7 @@ class BackendServer {
22
22
  process.once('SIGTERM', () => this.stop());
23
23
  // sentryService.install()
24
24
  // 2. Start Express Server
25
- const port = Number(process.env.PORT) || cfgPort || 8080;
25
+ const port = Number(process.env['PORT']) || cfgPort || 8080;
26
26
  this.server = await new Promise((resolve, reject) => {
27
27
  const server = expressApp.listen(port, (err) => {
28
28
  if (err)
@@ -32,15 +32,21 @@ class BackendServer {
32
32
  });
33
33
  // This is to fix GCP LoadBalancer race condition
34
34
  this.server.keepAliveTimeout = 600 * 1000; // 10 minutes
35
- const serverStartedAt = Date.now();
36
- const bootstrapMillis = serverStartedAt - bootstrapStartedAt;
37
- (0, log_1.log)(`serverStarted on port ${(0, colors_1.white)(String(port))}, bootstrapTime ${(0, colors_1.dimGrey)((0, js_lib_1._ms)(bootstrapMillis))}`);
35
+ let address = `http://localhost:${port}`; // default
36
+ const addr = this.server.address();
37
+ if (addr) {
38
+ if (typeof addr === 'string') {
39
+ address = addr;
40
+ }
41
+ else if (addr.address !== '::') {
42
+ address = `http://${addr.address}:${port}`;
43
+ }
44
+ }
45
+ (0, log_1.log)(`serverStarted on ${(0, colors_1.white)(address)} in ${(0, colors_1.dimGrey)((0, js_lib_1._ms)(process.uptime() * 1000))}`);
38
46
  return {
39
47
  port,
40
- bootstrapStartedAt,
41
- serverStartedAt,
42
- bootstrapMillis,
43
48
  server: this.server,
49
+ address,
44
50
  };
45
51
  }
46
52
  /**
@@ -53,9 +59,7 @@ class BackendServer {
53
59
  (0, log_1.log)((0, colors_1.boldGrey)('Forceful shutdown after timeout'));
54
60
  process.exit(1);
55
61
  }, this.cfg.forceShutdownTimeout || 3000);
56
- if (this.cfg.onShutdown) {
57
- void this.cfg.onShutdown();
58
- }
62
+ void this.cfg.onShutdown?.();
59
63
  try {
60
64
  if (this.server) {
61
65
  await new Promise(r => this.server.close(r));
@@ -64,7 +68,7 @@ class BackendServer {
64
68
  process.exit(0);
65
69
  }
66
70
  catch (err) {
67
- log_1.log.error(err);
71
+ console.error(err);
68
72
  process.exit(1);
69
73
  }
70
74
  }
@@ -6,12 +6,6 @@ export interface StartServerCfg {
6
6
  * @default process.env.PORT || 8080
7
7
  */
8
8
  port?: number;
9
- /**
10
- * Unix millisecond timestamp of when bootstrap has started.
11
- *
12
- * @default to Date.now()
13
- */
14
- bootstrapStartedAt?: number;
15
9
  expressApp: Application;
16
10
  /**
17
11
  * Server will wait for promise to resolve until shutting down.
@@ -25,8 +19,9 @@ export interface StartServerCfg {
25
19
  }
26
20
  export interface StartServerData {
27
21
  port: number;
28
- bootstrapStartedAt: number;
29
- serverStartedAt: number;
30
- bootstrapMillis: number;
31
22
  server: Server;
23
+ /**
24
+ * "Processed" server.address() as a string, ready to Cmd+click in MacOS Terminal
25
+ */
26
+ address: string;
32
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
- "version": "2.60.1",
3
+ "version": "2.61.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "docs-serve": "vuepress dev docs",
@@ -31,7 +31,7 @@
31
31
  "ejs": "^3.0.1",
32
32
  "express": "^4.16.4",
33
33
  "express-promise-router": "^4.0.0",
34
- "firebase-admin": "^9.0.0",
34
+ "firebase-admin": "^10.0.0",
35
35
  "fs-extra": "^10.0.0",
36
36
  "helmet": "^4.0.0",
37
37
  "js-yaml": "^4.0.0",
@@ -21,6 +21,12 @@ export interface RequireAdminCfg {
21
21
  apiHost?: string
22
22
 
23
23
  urlStartsWith?: string
24
+
25
+ /**
26
+ * Defaults to `true`.
27
+ * Set to `false` to debug login issues.
28
+ */
29
+ autoLogin?: boolean
24
30
  }
25
31
 
26
32
  export type AdminMiddleware = (reqPermissions?: string[], cfg?: RequireAdminCfg) => RequestHandler
@@ -47,7 +53,7 @@ export function requireAdminPermissions(
47
53
  reqPermissions: string[] = [],
48
54
  cfg: RequireAdminCfg = {},
49
55
  ): RequestHandler {
50
- const { loginHtmlPath = '/login.html', urlStartsWith, apiHost } = cfg
56
+ const { loginHtmlPath = '/login.html', urlStartsWith, apiHost, autoLogin = true } = cfg
51
57
 
52
58
  return async (req, res, next) => {
53
59
  if (urlStartsWith && !req.url.startsWith(urlStartsWith)) return next()
@@ -58,9 +64,9 @@ export function requireAdminPermissions(
58
64
  } catch (err) {
59
65
  if (err instanceof HttpError && (err.data as Admin401ErrorData).adminAuthRequired) {
60
66
  // Redirect to login.html
61
- const href = `${loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}${
62
- apiHost ? '&apiHost=' + apiHost : ''
63
- }`
67
+ const href = `${loginHtmlPath}?${
68
+ autoLogin ? 'autoLogin=1&' : ''
69
+ }returnUrl=\${encodeURIComponent(location.href)}${apiHost ? '&apiHost=' + apiHost : ''}`
64
70
  res.status(401).send(getLoginHtmlRedirect(href))
65
71
  } else {
66
72
  return next(err)
@@ -1,7 +1,7 @@
1
- import { Admin401ErrorData, Admin403ErrorData, HttpError } from '@naturalcycles/js-lib'
1
+ import { _assert, Admin401ErrorData, Admin403ErrorData, HttpError } from '@naturalcycles/js-lib'
2
2
  import { Debug, inspectAny } from '@naturalcycles/nodejs-lib'
3
3
  import { dimGrey, green, red } from '@naturalcycles/nodejs-lib/dist/colors'
4
- import { Request } from 'express'
4
+ import { Request, RequestHandler } from 'express'
5
5
  import type * as FirebaseAdmin from 'firebase-admin'
6
6
 
7
7
  const log = Debug('nc:backend-lib:admin')
@@ -228,4 +228,42 @@ export class BaseAdminService {
228
228
  ): Promise<AdminInfo> {
229
229
  return await this.requirePermissions(req, [reqPermission], meta)
230
230
  }
231
+
232
+ /**
233
+ * Install it on POST /admin/login url
234
+ *
235
+ * It takes a POST request with `Authentication` header, that contains `accessToken` from Firebase Auth.
236
+ * Backend doesn't validate the token, but only does `setCookie` (secure, httpOnly), returns http 204 (ok, empty response).
237
+ * Frontend (login.html page) will then proceed with redirecting to `returnUrl`.
238
+ *
239
+ * Same endpoint is used to logout, but the `Authentication` header should contain `logout` magic string.
240
+ */
241
+ getFirebaseAuthLoginHandler(): RequestHandler {
242
+ return async (req, res) => {
243
+ const token = req.header('authentication')
244
+ _assert(token, `401 Unauthenticated`, {
245
+ userFriendly: true,
246
+ httpStatusCode: 401,
247
+ })
248
+
249
+ let maxAge = 1000 * 60 * 60 * 24 * 30 // 30 days
250
+
251
+ // Special case
252
+ if (token === 'logout') {
253
+ // delete the cookie
254
+ maxAge = 0
255
+ }
256
+
257
+ res
258
+ .cookie(this.cfg.adminTokenKey, token, {
259
+ maxAge,
260
+ sameSite: 'lax', // can be: none, lax, strict
261
+ // comment these 2 lines to debug on localhost
262
+ httpOnly: true,
263
+ secure: true,
264
+ })
265
+ .status(204)
266
+ .end()
267
+ }
268
+ }
231
269
  }
@@ -6,11 +6,12 @@
6
6
  <meta charset="utf-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
8
8
 
9
- <script src="https://www.gstatic.com/firebasejs/6.3.5/firebase-app.js"></script>
10
- <script src="https://www.gstatic.com/firebasejs/6.3.5/firebase-auth.js"></script>
11
- <script src="https://unpkg.com/vue@2.6.10/dist/vue.min.js"></script>
12
-
13
- <link href="https://unpkg.com/bootstrap@4.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
9
+ <!-- CSS only -->
10
+ <link
11
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.2/dist/css/bootstrap.min.css"
12
+ rel="stylesheet"
13
+ crossorigin="anonymous"
14
+ />
14
15
  </head>
15
16
  <body>
16
17
  <div id="app" style="padding: 40px 50px">
@@ -28,33 +29,54 @@
28
29
  </div>
29
30
  </div>
30
31
 
31
- <script>
32
+ <script type="module">
33
+ import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3.2.20/dist/vue.esm-browser.prod.js'
34
+ import { initializeApp } from 'https://www.gstatic.com/firebasejs/9.1.2/firebase-app.js'
35
+ import {
36
+ getAuth,
37
+ GoogleAuthProvider,
38
+ onAuthStateChanged,
39
+ signInWithRedirect,
40
+ } from 'https://www.gstatic.com/firebasejs/9.1.2/firebase-auth.js'
41
+
32
42
  const apiKey = '<%= firebaseApiKey %>'
33
43
  const authDomain = '<%= firebaseAuthDomain %>'
34
- const authProvider = '<%= firebaseAuthProvider %>'
44
+ // const authProvider = '<%= firebaseAuthProvider %>'
35
45
 
36
46
  if (!apiKey || !authDomain) {
37
47
  alert(`Error: 'apiKey' or 'authDomain' is missing!`)
38
48
  }
39
49
 
40
- const qs = parseQuery(location.search)
41
- const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
42
- console.log(qs)
50
+ // Initialize Firebase
51
+ initializeApp({
52
+ apiKey,
53
+ authDomain,
54
+ })
43
55
 
44
- const provider = new firebase.auth[authProvider]()
56
+ const auth = getAuth()
57
+ const provider = new GoogleAuthProvider()
45
58
 
46
- const app = new Vue({
47
- el: '#app',
59
+ onAuthStateChanged(auth, user => {
60
+ // console.log('onAuthStateChanged, user: ', JSON.stringify(user, null, 2))
61
+ console.log('onAuthStateChanged, user: ', user)
62
+ onUser(user)
63
+ })
48
64
 
49
- data: {
50
- loading: 'Loading...',
51
- user: undefined,
52
- },
65
+ const qs = Object.fromEntries(new URLSearchParams(location.search))
66
+ const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
67
+ console.log(qs)
53
68
 
69
+ const app = createApp({
70
+ data() {
71
+ return {
72
+ loading: 'Loading...',
73
+ user: undefined,
74
+ }
75
+ },
54
76
  methods: {
55
77
  login: async function () {
56
78
  try {
57
- await firebase.auth().signInWithRedirect(provider)
79
+ await signInWithRedirect(auth, provider)
58
80
  } catch (err) {
59
81
  logError(err)
60
82
  }
@@ -62,7 +84,9 @@
62
84
 
63
85
  logout: async function () {
64
86
  try {
65
- await firebase.auth().signOut()
87
+ await auth.signOut()
88
+
89
+ await postToken('logout') // magic string
66
90
 
67
91
  if (logout && returnUrl) {
68
92
  alert('Logged out, redurecting back...')
@@ -73,20 +97,7 @@
73
97
  }
74
98
  },
75
99
  },
76
- })
77
-
78
- // Initialize Firebase
79
- const config = {
80
- apiKey,
81
- authDomain,
82
- }
83
- firebase.initializeApp(config)
84
-
85
- firebase.auth().onAuthStateChanged(user => {
86
- // console.log('onAuthStateChanged, user: ', JSON.stringify(user, null, 2))
87
- console.log('onAuthStateChanged, user: ', user)
88
- onUser(user)
89
- })
100
+ }).mount('#app')
90
101
 
91
102
  if (logout) app.logout()
92
103
 
@@ -100,13 +111,16 @@
100
111
  if (!user) {
101
112
  if (autoLogin) app.login()
102
113
  } else {
103
- const token = await firebase.auth().currentUser.getIdToken(true)
114
+ const token = await auth.currentUser.getIdToken()
115
+
104
116
  // alert('idToken')
105
117
  // console.log(idToken)
106
118
  app.user = Object.assign({}, app.user, {
107
119
  token,
108
120
  })
109
121
 
122
+ await postToken(token)
123
+
110
124
  // Redirect if needed
111
125
  if (returnUrl) {
112
126
  // alert(`Logged in as ${app.user.email}, redirecting back...`)
@@ -118,20 +132,19 @@
118
132
  }
119
133
  }
120
134
 
121
- function parseQuery(queryString) {
122
- const query = {}
123
- const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&')
124
- for (let i = 0; i < pairs.length; i++) {
125
- const pair = pairs[i].split('=')
126
- query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '')
127
- }
128
- return query
129
- }
130
-
131
135
  function logError(err) {
132
136
  console.error(err)
133
137
  alert('Error\n ' + JSON.stringify(err, null, 2))
134
138
  }
139
+
140
+ async function postToken(token) {
141
+ await fetch(`/admin/login`, {
142
+ method: 'post',
143
+ headers: {
144
+ Authentication: token,
145
+ },
146
+ })
147
+ }
135
148
  </script>
136
149
  </body>
137
150
  </html>
package/src/db/httpDB.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Readable } from 'stream'
2
- import { JsonSchemaRootObject } from '@naturalcycles/js-lib'
2
+ import { JsonSchemaRootObject, ObjectWithId } from '@naturalcycles/js-lib'
3
3
  import {
4
4
  BaseCommonDB,
5
5
  CommonDB,
@@ -7,7 +7,6 @@ import {
7
7
  CommonDBSaveOptions,
8
8
  CommonDBStreamOptions,
9
9
  DBQuery,
10
- ObjectWithId,
11
10
  RunQueryResult,
12
11
  } from '@naturalcycles/db-lib'
13
12
  import { getGot, GetGotOptions, Got, ReadableTyped } from '@naturalcycles/nodejs-lib'
@@ -4,13 +4,13 @@ import {
4
4
  CommonDBSaveOptions,
5
5
  DBQuery,
6
6
  InMemoryDB,
7
- ObjectWithId,
8
7
  } from '@naturalcycles/db-lib'
9
8
  import {
10
9
  commonDBOptionsSchema,
11
10
  commonDBSaveOptionsSchema,
12
11
  dbQuerySchema,
13
12
  } from '@naturalcycles/db-lib/dist/validation'
13
+ import { ObjectWithId } from '@naturalcycles/js-lib'
14
14
  import { anyObjectSchema, arraySchema, objectSchema, stringSchema } from '@naturalcycles/nodejs-lib'
15
15
  import { Router } from 'express'
16
16
  import { getDefaultRouter, reqValidation } from '..'
package/src/index.ts CHANGED
@@ -112,14 +112,3 @@ export {
112
112
  validateParams,
113
113
  validateQuery,
114
114
  }
115
-
116
- declare global {
117
- namespace NodeJS {
118
- interface ProcessEnv {
119
- PORT?: string
120
- GAE_APPLICATION?: string
121
- GAE_SERVICE?: string
122
- GAE_VERSION?: string
123
- }
124
- }
125
- }
@@ -23,6 +23,7 @@ export function reqValidation(
23
23
  if (opt.redactPaths) {
24
24
  redact(opt.redactPaths, req[reqProperty], error)
25
25
  error.data.joiValidationErrorItems.length = 0 // clears the array
26
+ delete error.data.annotation
26
27
  }
27
28
 
28
29
  return next(
@@ -4,27 +4,16 @@ import { dayjs } from '@naturalcycles/time-lib'
4
4
  import { RequestHandler } from 'express'
5
5
  import { getDeployInfo } from '../deployInfo.util'
6
6
 
7
- /**
8
- * @returns unix timestamp in millis
9
- */
10
- type ServerStartedCallback = () => number | undefined
11
-
12
- const now = Date.now()
13
- const defaultServerStartedCallback = () => now
14
7
  const { versions } = process
8
+ const { GAE_APPLICATION, GAE_SERVICE, GAE_VERSION } = process.env
15
9
 
16
- export function statusHandler(
17
- serverStartedCallback?: ServerStartedCallback,
18
- projectDir?: string,
19
- extra?: any,
20
- ): RequestHandler {
10
+ export function statusHandler(projectDir?: string, extra?: any): RequestHandler {
21
11
  return async (req, res) => {
22
- res.json(statusHandlerData(serverStartedCallback, projectDir, extra))
12
+ res.json(statusHandlerData(projectDir, extra))
23
13
  }
24
14
  }
25
15
 
26
16
  export function statusHandlerData(
27
- serverStartedCallback: ServerStartedCallback = defaultServerStartedCallback,
28
17
  projectDir: string = process.cwd(),
29
18
  extra?: any,
30
19
  ): Record<string, any> {
@@ -34,14 +23,14 @@ export function statusHandlerData(
34
23
  const buildInfo = [dayjs.unix(ts).toCompactTime(), gitBranch, gitRev].filter(Boolean).join('_')
35
24
 
36
25
  return _filterFalsyValues({
37
- started: getStartedStr(serverStartedCallback()),
26
+ started: getStartedStr(),
38
27
  deployBuildTimeUTC,
39
28
  APP_ENV,
40
29
  prod,
41
30
  buildInfo,
42
- GAE_APPLICATION: process.env.GAE_APPLICATION,
43
- GAE_SERVICE: process.env.GAE_SERVICE,
44
- GAE_VERSION: process.env.GAE_VERSION,
31
+ GAE_APPLICATION,
32
+ GAE_SERVICE,
33
+ GAE_VERSION,
45
34
  mem: memoryUsageFull(),
46
35
  cpuAvg: processSharedUtil.cpuAvg(),
47
36
  // resourceUsage: process.resourceUsage?.(),
@@ -50,8 +39,8 @@ export function statusHandlerData(
50
39
  })
51
40
  }
52
41
 
53
- function getStartedStr(serverStarted?: number): string {
54
- if (!serverStarted) return 'not started yet'
42
+ function getStartedStr(): string {
43
+ const serverStarted = dayjs.utc().subtract(process.uptime(), 's')
55
44
 
56
45
  const s1 = dayjs(serverStarted).toPretty()
57
46
  const s2 = dayjs(serverStarted).fromNow()
@@ -7,13 +7,6 @@ export interface StartServerCfg {
7
7
  */
8
8
  port?: number
9
9
 
10
- /**
11
- * Unix millisecond timestamp of when bootstrap has started.
12
- *
13
- * @default to Date.now()
14
- */
15
- bootstrapStartedAt?: number
16
-
17
10
  expressApp: Application
18
11
 
19
12
  /**
@@ -30,8 +23,9 @@ export interface StartServerCfg {
30
23
 
31
24
  export interface StartServerData {
32
25
  port: number
33
- bootstrapStartedAt: number
34
- serverStartedAt: number
35
- bootstrapMillis: number
36
26
  server: Server
27
+ /**
28
+ * "Processed" server.address() as a string, ready to Cmd+click in MacOS Terminal
29
+ */
30
+ address: string
37
31
  }
@@ -10,7 +10,7 @@ export class BackendServer {
10
10
  server?: Server
11
11
 
12
12
  async start(): Promise<StartServerData> {
13
- const { bootstrapStartedAt = Date.now(), port: cfgPort, expressApp } = this.cfg
13
+ const { port: cfgPort, expressApp } = this.cfg
14
14
 
15
15
  // 1. Register error handlers, etc.
16
16
  process.on('uncaughtException', err => {
@@ -27,7 +27,7 @@ export class BackendServer {
27
27
  // sentryService.install()
28
28
 
29
29
  // 2. Start Express Server
30
- const port = Number(process.env.PORT) || cfgPort || 8080
30
+ const port = Number(process.env['PORT']) || cfgPort || 8080
31
31
 
32
32
  this.server = await new Promise<Server>((resolve, reject) => {
33
33
  const server = expressApp.listen(port, (err?: Error) => {
@@ -39,21 +39,23 @@ export class BackendServer {
39
39
  // This is to fix GCP LoadBalancer race condition
40
40
  this.server.keepAliveTimeout = 600 * 1000 // 10 minutes
41
41
 
42
- const serverStartedAt = Date.now()
42
+ let address = `http://localhost:${port}` // default
43
43
 
44
- const bootstrapMillis = serverStartedAt - bootstrapStartedAt
45
- log(
46
- `serverStarted on port ${white(String(port))}, bootstrapTime ${dimGrey(
47
- _ms(bootstrapMillis),
48
- )}`,
49
- )
44
+ const addr = this.server.address()
45
+ if (addr) {
46
+ if (typeof addr === 'string') {
47
+ address = addr
48
+ } else if (addr.address !== '::') {
49
+ address = `http://${addr.address}:${port}`
50
+ }
51
+ }
52
+
53
+ log(`serverStarted on ${white(address)} in ${dimGrey(_ms(process.uptime() * 1000))}`)
50
54
 
51
55
  return {
52
56
  port,
53
- bootstrapStartedAt,
54
- serverStartedAt,
55
- bootstrapMillis,
56
57
  server: this.server,
58
+ address,
57
59
  }
58
60
  }
59
61
 
@@ -70,9 +72,7 @@ export class BackendServer {
70
72
  process.exit(1)
71
73
  }, this.cfg.forceShutdownTimeout || 3000)
72
74
 
73
- if (this.cfg.onShutdown) {
74
- void this.cfg.onShutdown()
75
- }
75
+ void this.cfg.onShutdown?.()
76
76
 
77
77
  try {
78
78
  if (this.server) {
@@ -81,7 +81,7 @@ export class BackendServer {
81
81
  log(dimGrey('Shutdown completed.'))
82
82
  process.exit(0)
83
83
  } catch (err) {
84
- log.error(err)
84
+ console.error(err)
85
85
  process.exit(1)
86
86
  }
87
87
  }