signatur 1.0.0 → 1.1.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.
Files changed (40) hide show
  1. package/README.md +18 -11
  2. package/app.js +189 -19
  3. package/lib/engines/inkscape.js +211 -30
  4. package/lib/util/auth.js +157 -0
  5. package/lib/util/config.js +39 -0
  6. package/lib/util/index.js +2 -0
  7. package/lib/util/profile.js +22 -0
  8. package/package.json +27 -19
  9. package/static/js/bundle.js +791 -24
  10. package/static/js/main.js +249 -8
  11. package/static/js/plugins/calligraphy.js +196 -0
  12. package/static/js/plugins/collapsible.js +2 -2
  13. package/static/js/plugins/console.js +0 -1
  14. package/static/js/plugins/diagnostics.js +160 -0
  15. package/static/js/plugins/feedback.js +147 -0
  16. package/static/js/plugins/inspiration.js +4 -4
  17. package/static/js/plugins/modal.js +14 -1
  18. package/static/js/plugins/texteditor.js +8 -8
  19. package/static/js/plugins/viewportpreview.js +8 -0
  20. package/test/lib/smoke.js +322 -0
  21. package/test/lib/util/auth.js +78 -0
  22. package/test/lib/util/profile.js +43 -0
  23. package/views/forbidden-pt_pt.ejs +29 -0
  24. package/views/forbidden.ejs +29 -0
  25. package/views/head.ejs +6 -0
  26. package/views/login-pt_pt.ejs +42 -0
  27. package/views/login.ejs +42 -0
  28. package/views/manager-pt_pt.ejs +1 -1
  29. package/views/manager.ejs +1 -1
  30. package/views/report-pt_pt.ejs +3 -1
  31. package/views/report.ejs +3 -1
  32. package/views/settings-pt_pt.ejs +63 -1
  33. package/views/settings.ejs +63 -1
  34. package/views/signature-pt_pt.ejs +3 -1
  35. package/views/signature.ejs +3 -1
  36. package/views/viewport-pt_pt.ejs +66 -1
  37. package/views/viewport.ejs +66 -1
  38. package/views/welcome-pt_pt.ejs +7 -2
  39. package/views/welcome.ejs +7 -2
  40. package/CHANGELOG.md +0 -307
package/README.md CHANGED
@@ -18,17 +18,24 @@ Supported file format include:
18
18
 
19
19
  ## Configuration
20
20
 
21
- | Name | Type | Default | Description |
22
- | ----------------- | ----- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
- | `BASE_URL` | `str` | `http://localhost:3000` | The base URL that is going to be used in the construction of external URLs for Signatur. |
24
- | `SIGNATUR_KEY` | `str` | `None` | Secret key that should be passed in protected calls so that the server side "trusts" the client side (authentication). |
25
- | `HEADLESS_URL` | `str` | `https://headless.stage.hive.pt` | The base URL to be used to access [Headless](https://github.com/hivesolutions/headless). |
26
- | `PRINT_URL` | `str` | `https://colony-print.stage.hive.pt` | Base URL of the [Colony Print](http://colony-print.hive.pt) service used for both the engraving job and the receipt printing. |
27
- | `PRINT_NODE` | `str` | `default` | Name of the Colony Print node to use when printing the report receipt. |
28
- | `PRINT_PRINTER` | `str` | `printer` | Name of the printer (within the receipt node) to use when printing the report receipt. |
29
- | `PRINT_KEY` | `str` | `null` | Secret key used to authenticate against Colony Print; shared by both the engraving job and the receipt printing. |
30
- | `ENGRAVE_NODE` | `str` | value of `PRINT_NODE` | Name of the Colony Print node to use when sending an engraving job; falls back to `PRINT_NODE` so existing single-printer deployments keep working. |
31
- | `ENGRAVE_PRINTER` | `str` | value of `PRINT_PRINTER` | Name of the printer (within the engrave node) to use when sending an engraving job; falls back to `PRINT_PRINTER` for the same backward compat reason. |
21
+ | Name | Type | Default | Description |
22
+ | --------------------- | ------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `BASE_URL` | `str` | `http://localhost:3000` | The base URL that is going to be used in the construction of external URLs for Signatur. |
24
+ | `SIGNATUR_KEY` | `str` | `None` | Secret key that should be passed in protected calls so that the server side "trusts" the client side (authentication). |
25
+ | `HEADLESS_URL` | `str` | `https://headless.stage.hive.pt` | The base URL to be used to access [Headless](https://github.com/hivesolutions/headless). |
26
+ | `PRINT_URL` | `str` | `https://colony-print.stage.hive.pt` | Base URL of the [Colony Print](http://colony-print.hive.pt) service used for both the engraving job and the receipt printing. |
27
+ | `PRINT_NODE` | `str` | `default` | Name of the Colony Print node to use when printing the report receipt. |
28
+ | `PRINT_PRINTER` | `str` | `printer` | Name of the printer (within the receipt node) to use when printing the report receipt. |
29
+ | `PRINT_KEY` | `str` | `null` | Secret key used to authenticate against Colony Print; shared by both the engraving job and the receipt printing. |
30
+ | `ENGRAVE_NODE` | `str` | value of `PRINT_NODE` | Name of the Colony Print node to use when sending an engraving job; falls back to `PRINT_NODE` so existing single-printer deployments keep working. |
31
+ | `ENGRAVE_PRINTER` | `str` | value of `PRINT_PRINTER` | Name of the printer (within the engrave node) to use when sending an engraving job; falls back to `PRINT_PRINTER` for the same backward compat reason. |
32
+ | `FEATURE_CALLIGRAPHY` | `bool` | `false` | Base value of the calligraphy feature flag; when set to a truthy value (`1`, `true`, `yes`, `on`) the calligraphy mode controls render on `/viewport`. May be overridden per session through the `Features` tab on `/settings`. |
33
+
34
+ ## Authentication
35
+
36
+ Every interactive route is gated behind a session login. The list of valid users lives in `config/users.json` (gitignored, with a `config/users.json.example` checked into the repository) as an array of `{ "username": "...", "password_hash": "$2a$...", "role": "admin" | "user" }` entries; the `role` controls whether the user can reach the admin-only surfaces (`/settings`, `/settings/diagnostics`, `/profiles/*`) or just the basic engraving flow.
37
+
38
+ New users are added through the `npm run user:add <username> <role>` helper which prompts for the password twice (no echo), bcrypts it at cost 10 and rewrites `config/users.json` in place; the running application picks the change up automatically through `fs.watch` so no restart is required. The bare `/login` and `/logout` routes, the `/info` endpoint, the static assets and the engine `/convert` endpoint (key authenticated through `SIGNATUR_KEY`) stay public.
32
39
 
33
40
  ## Query Parameters
34
41
 
package/app.js CHANGED
@@ -7,7 +7,6 @@ const process = require("process");
7
7
  const bodyParser = require("body-parser");
8
8
  const multer = require("multer");
9
9
  const JSZip = require("jszip");
10
- const fetch = require("node-fetch");
11
10
  const ejs = require("ejs");
12
11
  const util = require("hive-js-util");
13
12
  const info = require("./package");
@@ -60,6 +59,39 @@ app.locals.dev = process.env.NODE_ENV !== "production";
60
59
  app.use("/static", express.static(path.join(__dirname, "static")));
61
60
  app.use(bodyParser.urlencoded({ extended: true }));
62
61
 
62
+ // list of public route prefixes that bypass the global require
63
+ // user middleware so the login flow, the favicon and the public
64
+ // info endpoint can be reached without an authenticated session;
65
+ // the engine convert endpoint stays public so colony print can
66
+ // keep posting svgs validated through the existing key header,
67
+ // the static mount falls past express.static for missing assets
68
+ // so it must be public to keep returning a clean 404 instead of
69
+ // redirecting to /login, and the text route is hit by Headless
70
+ // from /receipt and /image without the user's cookie so it must
71
+ // also stay reachable for those rendering flows to work
72
+ const PUBLIC_PATHS = [
73
+ "/login",
74
+ "/logout",
75
+ "/info",
76
+ "/favicon.ico",
77
+ "/static",
78
+ "/convert",
79
+ "/text"
80
+ ];
81
+
82
+ // global authentication middleware that enforces a logged in
83
+ // user on every interactive route, allowing only the small list
84
+ // of public paths above through; admin only routes apply the
85
+ // `lib.requireAdmin` middleware directly below
86
+ app.use((req, res, next) => {
87
+ const isPublic = PUBLIC_PATHS.some(prefix => req.path === prefix || req.path.startsWith(prefix + "/"));
88
+ if (isPublic) {
89
+ next();
90
+ return;
91
+ }
92
+ lib.requireUser(req, res, next);
93
+ });
94
+
63
95
  // configures the multer middleware that parses the multipart
64
96
  // payload of the profile form, accepting only text fields since
65
97
  // background images are now managed by the dedicated asset
@@ -91,6 +123,45 @@ app.get("/", (req, res, next) => {
91
123
  res.redirect(302, home);
92
124
  });
93
125
 
126
+ app.get("/login", (req, res, next) => {
127
+ const theme = req.query.theme || req.session.theme || "";
128
+ const locale = req.query.locale || req.session.locale || "";
129
+ const nextUrl = typeof req.query.next === "string" ? req.query.next : "";
130
+ const error = typeof req.query.error === "string" ? req.query.error : "";
131
+ res.render("login" + (locale ? `-${locale}` : ""), {
132
+ theme: theme,
133
+ next: nextUrl,
134
+ error: error
135
+ });
136
+ });
137
+
138
+ app.post("/login", async (req, res, next) => {
139
+ async function clojure() {
140
+ const username = typeof req.body.username === "string" ? req.body.username.trim() : "";
141
+ const password = typeof req.body.password === "string" ? req.body.password : "";
142
+ const nextRaw = typeof req.body.next === "string" ? req.body.next : "";
143
+ const safeNext = nextRaw.startsWith("/") && !nextRaw.startsWith("//") ? nextRaw : "/";
144
+ const user = await lib.verifyCredentials(username, password);
145
+ if (!user) {
146
+ const params = new URLSearchParams();
147
+ params.set("error", "invalid");
148
+ if (nextRaw) params.set("next", nextRaw);
149
+ res.redirect(302, "/login?" + params.toString());
150
+ return;
151
+ }
152
+ req.session.user = user;
153
+ res.redirect(302, safeNext);
154
+ }
155
+ clojure().catch(next);
156
+ });
157
+
158
+ const handleLogout = (req, res) => {
159
+ if (req.session) req.session.user = null;
160
+ res.redirect(302, "/login");
161
+ };
162
+ app.get("/logout", handleLogout);
163
+ app.post("/logout", handleLogout);
164
+
94
165
  app.get("/gateway", (req, res, next) => {
95
166
  const fullscreen =
96
167
  req.query.fullscreen !== undefined
@@ -144,7 +215,7 @@ app.post("/gateway", (req, res, next) => {
144
215
  }
145
216
  });
146
217
 
147
- app.get("/settings", (req, res, next) => {
218
+ app.get("/settings", lib.requireAdmin, (req, res, next) => {
148
219
  const fullscreen =
149
220
  req.query.fullscreen !== undefined
150
221
  ? req.query.fullscreen === "1"
@@ -167,12 +238,18 @@ app.get("/settings", (req, res, next) => {
167
238
  home: req.session.home === "welcome" ? "welcome" : "gateway",
168
239
  showOptions: req.session.show_options !== "0",
169
240
  viewportMode: req.session.viewport_mode === "store" ? "store" : "technical",
241
+ features: lib.resolveFeatures(req.session),
242
+ featuresBase: lib.conf.FEATURES || {},
243
+ featuresOverride: Object.keys(lib.FEATURES || {}).reduce((accumulator, name) => {
244
+ accumulator[name] = req.session["feature_" + name];
245
+ return accumulator;
246
+ }, {}),
170
247
  next: nextUrl,
171
248
  info: info || {}
172
249
  });
173
250
  });
174
251
 
175
- app.post("/settings", (req, res, next) => {
252
+ app.post("/settings", lib.requireAdmin, (req, res, next) => {
176
253
  const theme = req.body.theme || "";
177
254
  const locale = req.body.locale || "";
178
255
  req.session.theme = theme;
@@ -181,13 +258,27 @@ app.post("/settings", (req, res, next) => {
181
258
  req.session.show_options = req.body.show_options === "0" ? "0" : "1";
182
259
  req.session.viewport_mode = req.body.viewport_mode === "store" ? "store" : "technical";
183
260
 
261
+ // persists every declared feature flag override sent through the
262
+ // features tab onto the session, treating `1` / `0` as explicit
263
+ // overrides and anything else as a clear so the base value from
264
+ // the matching `FEATURE_<NAME>` env var takes over again
265
+ for (const name of Object.keys(lib.FEATURES || {})) {
266
+ const value = req.body["feature_" + name];
267
+ if (value === "1") req.session["feature_" + name] = "1";
268
+ else if (value === "0") req.session["feature_" + name] = "0";
269
+ else delete req.session["feature_" + name];
270
+ }
271
+
184
272
  // resolves the redirect target from the submitted next field
185
273
  // restricting it to local paths so the form cannot be used as
186
- // an open redirect
274
+ // an open redirect, falling back to the bare `/` so the user
275
+ // lands on the configured home (gateway or welcome) according
276
+ // to the freshly saved `home` preference instead of being
277
+ // hardcoded to the welcome screen
187
278
  const target =
188
279
  typeof req.body.next === "string" && req.body.next.startsWith("/")
189
280
  ? req.body.next
190
- : "/welcome";
281
+ : "/";
191
282
 
192
283
  // persists the fullscreen flag onto the session so that it
193
284
  // survives the next request without polluting the redirect
@@ -197,6 +288,18 @@ app.post("/settings", (req, res, next) => {
197
288
  res.redirect(302, target);
198
289
  });
199
290
 
291
+ app.post("/settings/diagnostics", lib.requireAdmin, (req, res, next) => {
292
+ async function clojure() {
293
+ const engine = lib.ENGINES.inkscape.singleton();
294
+ const probes = await engine.probe();
295
+ const fixturePath = path.join(__dirname, "res", "diagnostic.svg");
296
+ const svgBuffer = await fs.readFile(fixturePath);
297
+ const steps = await engine.diagnose(svgBuffer);
298
+ res.json({ probes: probes, steps: steps });
299
+ }
300
+ clojure().catch(next);
301
+ });
302
+
200
303
  app.get("/welcome", (req, res, next) => {
201
304
  const fullscreen =
202
305
  req.query.fullscreen !== undefined
@@ -214,7 +317,8 @@ app.get("/welcome", (req, res, next) => {
214
317
  masterb64: masterb64,
215
318
  config: req.session.config || {},
216
319
  showOptions: req.session.show_options !== "0",
217
- info: info || {}
320
+ info: info || {},
321
+ user: req.session.user || null
218
322
  });
219
323
  });
220
324
 
@@ -231,7 +335,7 @@ app.get("/signature", (req, res, next) => {
231
335
  res.render("signature" + (locale ? `-${locale}` : ""), {
232
336
  fullscreen: fullscreen,
233
337
  theme: theme,
234
- back: req.session.entry === "welcome" ? "/welcome" : "/"
338
+ back: "/"
235
339
  });
236
340
  });
237
341
 
@@ -247,6 +351,7 @@ app.get("/viewport", (req, res, next) => {
247
351
  req.session.locale = locale;
248
352
  req.session.config = req.session.config || {};
249
353
  req.session.config.text = req.query.text || req.session.config.text || null;
354
+ const features = lib.resolveFeatures(req.session);
250
355
  res.render("viewport" + (locale ? `-${locale}` : ""), {
251
356
  fullscreen: fullscreen,
252
357
  theme: theme,
@@ -257,7 +362,9 @@ app.get("/viewport", (req, res, next) => {
257
362
  config: req.session.config || {},
258
363
  text: lib.deserializeText(req.session.config.text) || null,
259
364
  viewportMode: req.session.viewport_mode === "store" ? "store" : "technical",
260
- back: req.session.entry === "welcome" ? "/welcome" : "/"
365
+ features: features,
366
+ featuresb64: Buffer.from(JSON.stringify(features)).toString("base64"),
367
+ back: "/"
261
368
  });
262
369
  });
263
370
 
@@ -282,7 +389,7 @@ app.get("/report", (req, res, next) => {
282
389
  text: lib.deserializeText(req.session.config.text) || null,
283
390
  font: lib.fontText(req.session.config.text) || null,
284
391
  localize: (v, f) => lib.localize(v, locale || undefined, f),
285
- back: req.session.entry === "welcome" ? "/welcome" : "/"
392
+ back: "/"
286
393
  });
287
394
  });
288
395
 
@@ -362,11 +469,74 @@ app.post("/convert", (req, res, next) => {
362
469
  clojure().catch(next);
363
470
  });
364
471
 
472
+ app.post("/feedback", (req, res, next) => {
473
+ async function clojure() {
474
+ // ensures the feedback feature is currently enabled for the
475
+ // requester so disabled deployments do not accept submissions
476
+ const features = lib.resolveFeatures(req.session);
477
+ if (!features.feedback) {
478
+ res.status(404).json({ error: "feedback feature is disabled" });
479
+ return;
480
+ }
481
+
482
+ // validates the satisfaction value against the small set of
483
+ // allowed multiple choice answers so the on disk payload stays
484
+ // consistent and easy to aggregate later
485
+ const allowedSatisfaction = new Set([
486
+ "very_satisfied",
487
+ "satisfied",
488
+ "neutral",
489
+ "unsatisfied",
490
+ "very_unsatisfied"
491
+ ]);
492
+ const satisfaction = typeof req.body.satisfaction === "string" ? req.body.satisfaction : "";
493
+ if (!allowedSatisfaction.has(satisfaction)) {
494
+ res.status(400).json({ error: "invalid satisfaction value" });
495
+ return;
496
+ }
497
+
498
+ // captures the optional free text notes, trimming any leading
499
+ // or trailing whitespace and applying a sensible upper bound
500
+ // so the persisted entries cannot grow unbounded
501
+ const notesRaw = typeof req.body.notes === "string" ? req.body.notes : "";
502
+ const notes = notesRaw.trim().slice(0, 2000);
503
+
504
+ // builds the entry payload, capturing the contextual fields the
505
+ // client sends so the feedback can be cross referenced with the
506
+ // engraving submission it relates to
507
+ const entry = {
508
+ id: crypto.randomUUID(),
509
+ timestamp: new Date().toISOString(),
510
+ satisfaction: satisfaction,
511
+ notes: notes,
512
+ profile: typeof req.body.profile === "string" ? req.body.profile : null,
513
+ variant: typeof req.body.variant === "string" ? req.body.variant : null,
514
+ locale: req.session.locale || null
515
+ };
516
+
517
+ // groups the feedback submissions by year, month and day in
518
+ // separate subdirectories derived from the ISO timestamp so
519
+ // the on disk layout stays easy to navigate as the number of
520
+ // entries grows, while each entry remains its own JSON file
521
+ // named after the generated identifier for easy inspection
522
+ // and per file aggregation
523
+ const year = entry.timestamp.slice(0, 4);
524
+ const month = entry.timestamp.slice(5, 7);
525
+ const day = entry.timestamp.slice(8, 10);
526
+ const directoryPath = path.join(__dirname, "data", "feedback", year, month, day);
527
+ await fs.mkdir(directoryPath, { recursive: true });
528
+ const entryPath = path.join(directoryPath, `${entry.id}.json`);
529
+ await fs.writeFile(entryPath, JSON.stringify(entry, null, 4) + "\n", "utf8");
530
+ res.json({ id: entry.id });
531
+ }
532
+ clojure().catch(next);
533
+ });
534
+
365
535
  app.get("/config", (req, res, next) => {
366
536
  res.json(req.session.config || {});
367
537
  });
368
538
 
369
- app.get("/profiles/manager", (req, res, next) => {
539
+ app.get("/profiles/manager", lib.requireAdmin, (req, res, next) => {
370
540
  async function clojure() {
371
541
  const fullscreen =
372
542
  req.query.fullscreen !== undefined
@@ -426,7 +596,7 @@ app.get("/profiles/manager", (req, res, next) => {
426
596
  clojure().catch(next);
427
597
  });
428
598
 
429
- app.post("/profiles/validate", profileUpload, (req, res, next) => {
599
+ app.post("/profiles/validate", lib.requireAdmin, profileUpload, (req, res, next) => {
430
600
  async function clojure() {
431
601
  const editTarget = typeof req.body.edit_target === "string" ? req.body.edit_target : "";
432
602
  const { profile, errors } = lib.validateProfileSubmission(
@@ -445,7 +615,7 @@ app.post("/profiles/validate", profileUpload, (req, res, next) => {
445
615
  clojure().catch(next);
446
616
  });
447
617
 
448
- app.post("/profiles", profileUpload, (req, res, next) => {
618
+ app.post("/profiles", lib.requireAdmin, profileUpload, (req, res, next) => {
449
619
  async function clojure() {
450
620
  const profileText = req.body.profile_json || "";
451
621
  const inspirationsText = req.body.inspirations_json || "";
@@ -507,7 +677,7 @@ app.post("/profiles", profileUpload, (req, res, next) => {
507
677
  clojure().catch(next);
508
678
  });
509
679
 
510
- app.post("/profiles/:id/enabled", profileUpload, (req, res, next) => {
680
+ app.post("/profiles/:id/enabled", lib.requireAdmin, profileUpload, (req, res, next) => {
511
681
  async function clojure() {
512
682
  const id = req.params.id;
513
683
  if (!lib.PROFILE_ID_PATTERN.test(id)) {
@@ -546,7 +716,7 @@ app.post("/profiles/:id/enabled", profileUpload, (req, res, next) => {
546
716
  clojure().catch(next);
547
717
  });
548
718
 
549
- app.post("/profiles/:id/delete", (req, res, next) => {
719
+ app.post("/profiles/:id/delete", lib.requireAdmin, (req, res, next) => {
550
720
  async function clojure() {
551
721
  const id = req.params.id;
552
722
  if (!lib.PROFILE_ID_PATTERN.test(id)) {
@@ -584,7 +754,7 @@ app.post("/profiles/:id/delete", (req, res, next) => {
584
754
  clojure().catch(next);
585
755
  });
586
756
 
587
- app.get("/profiles/assets", (req, res, next) => {
757
+ app.get("/profiles/assets", lib.requireAdmin, (req, res, next) => {
588
758
  async function clojure() {
589
759
  const directoryPath = path.join(__dirname, "static", "profiles");
590
760
  const files = await fs.readdir(directoryPath);
@@ -594,7 +764,7 @@ app.get("/profiles/assets", (req, res, next) => {
594
764
  clojure().catch(next);
595
765
  });
596
766
 
597
- app.post("/profiles/assets", assetUpload, (req, res, next) => {
767
+ app.post("/profiles/assets", lib.requireAdmin, assetUpload, (req, res, next) => {
598
768
  async function clojure() {
599
769
  const errors = [];
600
770
 
@@ -651,7 +821,7 @@ app.post("/profiles/assets", assetUpload, (req, res, next) => {
651
821
  clojure().catch(next);
652
822
  });
653
823
 
654
- app.post("/profiles/assets/:filename/delete", (req, res, next) => {
824
+ app.post("/profiles/assets/:filename/delete", lib.requireAdmin, (req, res, next) => {
655
825
  async function clojure() {
656
826
  const filename = req.params.filename;
657
827
  if (!lib.ASSET_FILENAME_PATTERN.test(filename)) {
@@ -673,7 +843,7 @@ app.post("/profiles/assets/:filename/delete", (req, res, next) => {
673
843
  clojure().catch(next);
674
844
  });
675
845
 
676
- app.get("/profiles/bundle", (req, res, next) => {
846
+ app.get("/profiles/bundle", lib.requireAdmin, (req, res, next) => {
677
847
  async function clojure() {
678
848
  const directoryPath = path.join(__dirname, "static", "profiles");
679
849
  const files = await fs.readdir(directoryPath);
@@ -709,7 +879,7 @@ app.get("/profiles/bundle", (req, res, next) => {
709
879
  clojure().catch(next);
710
880
  });
711
881
 
712
- app.post("/profiles/bundle", bundleUpload, (req, res, next) => {
882
+ app.post("/profiles/bundle", lib.requireAdmin, bundleUpload, (req, res, next) => {
713
883
  async function clojure() {
714
884
  if (!req.file) {
715
885
  res.status(400).json({ error: "file is required" });