signatur 1.1.0 → 1.2.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
@@ -21,6 +21,8 @@ Supported file format include:
21
21
  | Name | Type | Default | Description |
22
22
  | --------------------- | ------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
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
+ | `SESSION_SECRET` | `str` | `signatur` | HMAC signing key for the `signatur.sid` cookie-session. Comma separated to support rotation: the first entry signs new cookies, the rest still validate previously issued ones. Changing the key invalidates all live sessions. |
25
+ | `SESSION_MAX_AGE` | `int` | `15552000000` | Lifetime of the `signatur.sid` cookie in milliseconds; the default is ~6 months and the cookie is not rolling, so even active users are logged out once it elapses. |
24
26
  | `SIGNATUR_KEY` | `str` | `None` | Secret key that should be passed in protected calls so that the server side "trusts" the client side (authentication). |
25
27
  | `HEADLESS_URL` | `str` | `https://headless.stage.hive.pt` | The base URL to be used to access [Headless](https://github.com/hivesolutions/headless). |
26
28
  | `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. |
@@ -35,7 +37,7 @@ Supported file format include:
35
37
 
36
38
  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
39
 
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.
40
+ New users are added through the `npm run user:add <username> <role> [password]` helper which prompts for the password twice (no echo) when the third positional argument is omitted, bcrypts the value at cost 10 and rewrites `config/users.json` in place; passing the password as the optional third argument skips the interactive prompts so the same helper can be driven from CI scripts or container entrypoints, while the running application picks every 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.
39
41
 
40
42
  ## Query Parameters
41
43
 
@@ -133,6 +135,8 @@ The configuration is resolved through a three step fallback chain so existing si
133
135
 
134
136
  The settings page exposes a `Printing` readout that shows the server side base value next to the effective resolved value for each entry, so an operator can verify at a glance which side is active.
135
137
 
138
+ When the engraving job is submitted, the confirm modal walks the multifont array of the print payload, rewrites every `Cool Emojis` entry into the underlying engraving glyph name through the active `coolemojis.mapping.json` and then resolves the engraving `.f3s` payload of every referenced font through a `GET /settings/fonts/resolve?names=...` call. The resulting `{ name: base64 }` map is attached to the gravo print payload as the `extra_fonts` field that colony print plumbs through to gravo pilot on a per print job basis (see [hivesolutions/colony-print#20](https://github.com/hivesolutions/colony-print/issues/20) and [hivesolutions/gravo-pilot#22](https://github.com/hivesolutions/gravo-pilot/issues/22) for the receiving end).
139
+
136
140
  ### Configuration keys
137
141
 
138
142
  | `localStorage` key | Scenario | Server side fallback | Description |
@@ -179,13 +183,58 @@ The same configuration can also be edited through the `Configure` modal accessib
179
183
 
180
184
  ### Adding New Emoji
181
185
 
182
- To add a new emoji to the system the following steps should be followed:
186
+ To add a new emoji to the system, sign in as an admin and use the `Emojis` tab on `/settings`:
187
+
188
+ 1. Upload the replacement `coolemojis.ttf` (display) and, when the catalog of recognised characters changes, the companion `coolemojis.mapping.json`.
189
+ 2. Upload the matching engraving glyph as a `.f3s` file in the `Engraving glyphs` section. The filename must match a value declared on the mapping (e.g. `1101.coracao.f3s`).
190
+ 3. The next time the engraving viewport is opened the new glyphs are picked up automatically; shipping the `.f3s` payload to colony print on every print job through the `extra_fonts` field of the gravo print payload is tracked in #56.
191
+
192
+ The admin UI replaces the previous manual file drop. The on disk layout it owns is documented in [Font Management](#font-management).
193
+
194
+ ### Adding New Fonts
195
+
196
+ To add a new text font (Helvetica, Roman, Script, etc.) to the system, sign in as an admin and use the `Fonts` tab on `/settings`:
197
+
198
+ 1. Pick a lowercase hyphenated font name (e.g. `helvetica4l`).
199
+ 2. Upload both halves together: the display `.ttf` (browser font) and the engraving `.f3s` (engraving machine font). Both halves are required so the two surfaces stay in sync.
200
+ 3. The `Fonts` tab lists every installed font with both halves status; use the per row delete button to remove stale entries.
201
+
202
+ ## Font Management
203
+
204
+ The on disk font catalog owned by the admin UI lives under `static/fonts/`:
205
+
206
+ ```text
207
+ static/fonts/
208
+ coolemojis.ttf display, browser (Emojis tab)
209
+ coolemojis.mapping.json display to engraving bridge (Emojis tab)
210
+ helvetica4l.ttf display, browser (Fonts tab)
211
+ ...
212
+ f3s/
213
+ emoji/ engraving glyphs (Emojis tab)
214
+ 1101.coracao.f3s
215
+ ...
216
+ fonts/ engraving text fonts (Fonts tab)
217
+ helvetica4l.f3s
218
+ ...
219
+ ```
220
+
221
+ Filename invariants are validated server side:
222
+
223
+ * emoji `.f3s` files match `^[a-z0-9]+(?:[-.][a-z0-9]+)*\.f3s$` so the existing `1101.coracao` dotted form keeps working alongside a hyphenated form
224
+ * text font names match `^[a-z0-9]+(?:-[a-z0-9]+)*$` so both halves land at the canonical `<name>.ttf` and `<name>.f3s` paths
225
+
226
+ The following HTTP endpoints back the UI. Every entry is gated by `lib.requireAdmin` except the resolver at the bottom, which is callable by any signed in user so the print confirm modal can attach the engraving payloads to the print envelope without elevating privileges:
183
227
 
184
- 1. Determine the right file name for the new emoji font file (e.g. `coolemojis.ttf` for laser and `coolemojisp.ttf` for pantogrpah)
185
- 2. Place the new font file in the `static/fonts` directory
186
- 3. Add the new emoji "characters" to the `emoji` array in the `viewport.ejs` file
187
- 4. Test the using the local machine `yarn && yarn dev`
188
- 5. Release a new version of the system (Docker Image)
228
+ | Method | Path | Notes |
229
+ | ------ | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
230
+ | `POST` | `/settings/emojis` | Replace the Cool Emojis `.ttf` and, optionally, the companion mapping JSON. |
231
+ | `GET` | `/settings/emojis/f3s` | List installed emoji engraving glyphs as `{ fonts: [{ name, size, mtime }, ...] }`. |
232
+ | `POST` | `/settings/emojis/f3s` | Upload one emoji engraving glyph; form fields are `filename` plus the `file` payload. |
233
+ | `POST` | `/settings/emojis/f3s/:filename/delete` | Delete one emoji engraving glyph by filename. |
234
+ | `GET` | `/settings/fonts` | List installed text fonts as `{ fonts: [{ name, ttf, f3s }, ...] }` rows. |
235
+ | `POST` | `/settings/fonts` | Upload one paired text font; form fields are `name` plus the `ttf` and `f3s` file payloads. |
236
+ | `POST` | `/settings/fonts/:name/delete` | Delete both halves of a text font by canonical name. |
237
+ | `GET` | `/settings/fonts/resolve?names=a,b,c` | Resolve font names into a `{ fonts: { name: base64 } }` engraving payload map. |
189
238
 
190
239
  ## License
191
240
 
package/app.js CHANGED
@@ -23,14 +23,19 @@ const app = express();
23
23
 
24
24
  // initializes the session middleware using a cookie based store
25
25
  // so the whole session payload travels in the signed cookie and
26
- // no server side storage is required; the secret is hardcoded
27
- // since the session only carries non sensitive UI preferences
26
+ // no server side storage is required; the signing keys are read
27
+ // from `lib.conf.SESSION_SECRET` (comma separated to support
28
+ // rotation, first entry signs new cookies, the rest still
29
+ // validate old ones) and fall back to a placeholder so the
30
+ // local dev flow keeps working without any environment setup;
31
+ // the lifetime is `lib.conf.SESSION_MAX_AGE` (ms), defaulting
32
+ // to ~6 months
28
33
  app.use(
29
34
  cookieSession({
30
35
  name: "signatur.sid",
31
- keys: ["signatur"],
36
+ keys: lib.conf.SESSION_SECRET,
32
37
  httpOnly: true,
33
- maxAge: 60000000,
38
+ maxAge: lib.conf.SESSION_MAX_AGE,
34
39
  sameSite: "lax"
35
40
  })
36
41
  );
@@ -115,6 +120,40 @@ const bundleUpload = multer({
115
120
  limits: { fileSize: 50 * 1024 * 1024, files: 1 }
116
121
  }).single("file");
117
122
 
123
+ // configures the multer middleware that handles the cool emojis
124
+ // font upload, accepting a required TTF/OTF font payload and an
125
+ // optional mapping JSON payload kept in memory so both bodies can
126
+ // be validated upfront before the on disk fonts directory is
127
+ // touched
128
+ const emojisUpload = multer({
129
+ storage: multer.memoryStorage(),
130
+ limits: { fileSize: 5 * 1024 * 1024, files: 2 }
131
+ }).fields([
132
+ { name: "font", maxCount: 1 },
133
+ { name: "mapping", maxCount: 1 }
134
+ ]);
135
+
136
+ // configures the multer middleware that handles the multipart
137
+ // upload of a single emoji `.f3s` payload, keeping it in
138
+ // memory so the filename and the body can be validated before
139
+ // the engraving fonts directory is touched
140
+ const emojisF3sUpload = multer({
141
+ storage: multer.memoryStorage(),
142
+ limits: { fileSize: 5 * 1024 * 1024, files: 1 }
143
+ }).single("file");
144
+
145
+ // configures the multer middleware that handles the paired
146
+ // upload of a text font, accepting both the browser `.ttf`
147
+ // payload and the engraving `.f3s` payload together so the
148
+ // two halves stay consistent on disk
149
+ const fontsUpload = multer({
150
+ storage: multer.memoryStorage(),
151
+ limits: { fileSize: 5 * 1024 * 1024, files: 2 }
152
+ }).fields([
153
+ { name: "ttf", maxCount: 1 },
154
+ { name: "f3s", maxCount: 1 }
155
+ ]);
156
+
118
157
  app.get("/", (req, res, next) => {
119
158
  // forwards the bare root URL to the user's preferred home
120
159
  // landing page, defaulting to the classic gateway when no
@@ -300,6 +339,343 @@ app.post("/settings/diagnostics", lib.requireAdmin, (req, res, next) => {
300
339
  clojure().catch(next);
301
340
  });
302
341
 
342
+ app.post("/settings/emojis", lib.requireAdmin, emojisUpload, (req, res, next) => {
343
+ async function clojure() {
344
+ const errors = [];
345
+
346
+ // resolves the uploaded payloads from the multer fields output
347
+ // so the validation and the disk writes can treat the optional
348
+ // mapping body the same way regardless of whether the client
349
+ // included it on the submission
350
+ const fontFile = req.files && req.files.font ? req.files.font[0] : null;
351
+ const mappingFile = req.files && req.files.mapping ? req.files.mapping[0] : null;
352
+
353
+ if (!fontFile) {
354
+ errors.push("font is required");
355
+ } else {
356
+ for (const message of lib.validateEmojisFont(fontFile.buffer)) {
357
+ errors.push(message);
358
+ }
359
+ }
360
+
361
+ // walks the optional mapping payload through the validator so
362
+ // a malformed JSON body cannot land on disk and break the
363
+ // viewport consumer that expects character to name pairs for
364
+ // every glyph in the emoji catalog
365
+ if (mappingFile) {
366
+ const mappingText = mappingFile.buffer.toString("utf8");
367
+ for (const message of lib.validateEmojisMapping(mappingText)) {
368
+ errors.push(message);
369
+ }
370
+ }
371
+
372
+ if (errors.length > 0) {
373
+ res.status(400).json({ errors: errors });
374
+ return;
375
+ }
376
+
377
+ // writes the validated font payload over the existing display
378
+ // font and, when provided, the validated mapping body so the
379
+ // next viewport load picks up the freshly uploaded set without
380
+ // any further server side intervention
381
+ const fontsDirectory = path.join(__dirname, "static", "fonts");
382
+ const fontPath = path.join(fontsDirectory, "coolemojis.ttf");
383
+ await fs.writeFile(fontPath, fontFile.buffer);
384
+ if (mappingFile) {
385
+ const mappingPath = path.join(fontsDirectory, "coolemojis.mapping.json");
386
+ await fs.writeFile(mappingPath, mappingFile.buffer);
387
+ }
388
+
389
+ res.json({ status: "ok", mapping: Boolean(mappingFile) });
390
+ }
391
+ clojure().catch(next);
392
+ });
393
+
394
+ app.get("/settings/emojis/f3s", lib.requireAdmin, (req, res, next) => {
395
+ async function clojure() {
396
+ const directoryPath = path.join(__dirname, "static", "fonts", "f3s", "emoji");
397
+ let files = [];
398
+ try {
399
+ files = await fs.readdir(directoryPath);
400
+ } catch (err) {
401
+ // missing directory is treated as an empty catalog so the
402
+ // first ever upload can lazily create the tree without an
403
+ // out of band initialization step
404
+ }
405
+ const fonts = [];
406
+ for (const file of files) {
407
+ if (!lib.EMOJI_F3S_FILENAME_PATTERN.test(file)) continue;
408
+ const stat = await fs.stat(path.join(directoryPath, file));
409
+ fonts.push({ name: file, size: stat.size, mtime: stat.mtimeMs });
410
+ }
411
+ fonts.sort((a, b) => a.name.localeCompare(b.name));
412
+ res.json({ fonts: fonts });
413
+ }
414
+ clojure().catch(next);
415
+ });
416
+
417
+ app.post("/settings/emojis/f3s", lib.requireAdmin, emojisF3sUpload, (req, res, next) => {
418
+ async function clojure() {
419
+ const errors = [];
420
+
421
+ // requires both the filename and the file payload so the
422
+ // resulting entry has a deterministic on disk name that
423
+ // can be referenced from the bundled `coolemojis.mapping.json`
424
+ const filename = typeof req.body.filename === "string" ? req.body.filename.trim() : "";
425
+ if (!filename) {
426
+ errors.push("filename is required");
427
+ } else if (!lib.EMOJI_F3S_FILENAME_PATTERN.test(filename)) {
428
+ errors.push(
429
+ "filename must match pattern: lowercase alphanumeric with hyphens or dots and a .f3s extension"
430
+ );
431
+ }
432
+
433
+ if (!req.file) {
434
+ errors.push("file is required");
435
+ }
436
+
437
+ if (errors.length > 0) {
438
+ res.status(400).json({ errors: errors });
439
+ return;
440
+ }
441
+
442
+ // lazily creates the destination tree on the first ever
443
+ // upload so the operator does not need to seed any folder
444
+ // before the admin upload becomes available
445
+ const directoryPath = path.join(__dirname, "static", "fonts", "f3s", "emoji");
446
+ await fs.mkdir(directoryPath, { recursive: true });
447
+ const targetPath = path.join(directoryPath, filename);
448
+ await fs.writeFile(targetPath, req.file.buffer);
449
+ res.json({ status: "ok", filename: filename });
450
+ }
451
+ clojure().catch(next);
452
+ });
453
+
454
+ app.post("/settings/emojis/f3s/:filename/delete", lib.requireAdmin, (req, res, next) => {
455
+ async function clojure() {
456
+ const filename = req.params.filename;
457
+ if (!lib.EMOJI_F3S_FILENAME_PATTERN.test(filename)) {
458
+ res.status(400).json({ error: "invalid f3s filename" });
459
+ return;
460
+ }
461
+
462
+ const directoryPath = path.join(__dirname, "static", "fonts", "f3s", "emoji");
463
+ const targetPath = path.join(directoryPath, filename);
464
+ try {
465
+ await fs.unlink(targetPath);
466
+ } catch (err) {
467
+ res.status(404).json({ error: "f3s entry not found" });
468
+ return;
469
+ }
470
+
471
+ res.json({ status: "ok" });
472
+ }
473
+ clojure().catch(next);
474
+ });
475
+
476
+ // names owned by the Emojis tab that must stay out of the Fonts
477
+ // catalog so deleting an entry from the Fonts tab can never unlink
478
+ // the display halves of the bundled Cool Emojis font face declared
479
+ // in `static/css/layout.css`
480
+ const EMOJIS_OWNED_FONTS = new Set(["coolemojis", "coolemojisp"]);
481
+
482
+ app.get("/settings/fonts", lib.requireAdmin, (req, res, next) => {
483
+ async function clojure() {
484
+ const fontsDirectory = path.join(__dirname, "static", "fonts");
485
+ const f3sDirectory = path.join(fontsDirectory, "f3s", "fonts");
486
+ let ttfFiles = [];
487
+ let f3sFiles = [];
488
+ try {
489
+ ttfFiles = await fs.readdir(fontsDirectory);
490
+ } catch (err) {
491
+ // missing root fonts directory is unexpected at runtime,
492
+ // but treating it as an empty catalog keeps the endpoint
493
+ // resilient during the very first deployment
494
+ }
495
+ try {
496
+ f3sFiles = await fs.readdir(f3sDirectory);
497
+ } catch (err) {
498
+ // missing f3s subdirectory is treated as no installed
499
+ // engraving payloads so the first ever upload can lazily
500
+ // create the tree without an out of band step
501
+ }
502
+
503
+ // collects the base names of every accepted `.ttf` and `.f3s`
504
+ // entry so the response can surface only the names that
505
+ // satisfy the paired upload requirement of the Fonts tab
506
+ // while still listing partial state for ops; the suffix
507
+ // checks are case sensitive so an uppercase `Font.TTF` entry
508
+ // is skipped instead of being later stat'd as a lowercase
509
+ // path that does not exist on case sensitive filesystems
510
+ const ttfNames = new Set();
511
+ const f3sNames = new Set();
512
+ for (const file of ttfFiles) {
513
+ if (!file.endsWith(".ttf")) continue;
514
+ const name = file.slice(0, -4);
515
+ if (!lib.FONT_NAME_PATTERN.test(name)) continue;
516
+ if (EMOJIS_OWNED_FONTS.has(name)) continue;
517
+ ttfNames.add(name);
518
+ }
519
+ for (const file of f3sFiles) {
520
+ if (!file.endsWith(".f3s")) continue;
521
+ const name = file.slice(0, -4);
522
+ if (!lib.FONT_NAME_PATTERN.test(name)) continue;
523
+ if (EMOJIS_OWNED_FONTS.has(name)) continue;
524
+ f3sNames.add(name);
525
+ }
526
+
527
+ const names = Array.from(new Set([...ttfNames, ...f3sNames]));
528
+ names.sort();
529
+ const fonts = [];
530
+ for (const name of names) {
531
+ const entry = { name: name, ttf: null, f3s: null };
532
+ if (ttfNames.has(name)) {
533
+ const stat = await fs.stat(path.join(fontsDirectory, `${name}.ttf`));
534
+ entry.ttf = { size: stat.size, mtime: stat.mtimeMs };
535
+ }
536
+ if (f3sNames.has(name)) {
537
+ const stat = await fs.stat(path.join(f3sDirectory, `${name}.f3s`));
538
+ entry.f3s = { size: stat.size, mtime: stat.mtimeMs };
539
+ }
540
+ fonts.push(entry);
541
+ }
542
+ res.json({ fonts: fonts });
543
+ }
544
+ clojure().catch(next);
545
+ });
546
+
547
+ app.post("/settings/fonts", lib.requireAdmin, fontsUpload, (req, res, next) => {
548
+ async function clojure() {
549
+ const errors = [];
550
+
551
+ // requires the font name plus both the browser and the
552
+ // engraving payloads so the resulting on disk pair stays
553
+ // consistent and can be uniquely identified through the
554
+ // canonical `<name>.ttf` and `<name>.f3s` filenames
555
+ const name = typeof req.body.name === "string" ? req.body.name.trim() : "";
556
+ if (!name) {
557
+ errors.push("name is required");
558
+ } else if (!lib.FONT_NAME_PATTERN.test(name)) {
559
+ errors.push(
560
+ "name must match pattern: lowercase alphanumeric with hyphens"
561
+ );
562
+ } else if (EMOJIS_OWNED_FONTS.has(name)) {
563
+ errors.push("name is reserved by the Emojis tab");
564
+ }
565
+
566
+ const ttfFile = req.files && req.files.ttf ? req.files.ttf[0] : null;
567
+ const f3sFile = req.files && req.files.f3s ? req.files.f3s[0] : null;
568
+ if (!ttfFile) errors.push("ttf is required");
569
+ if (!f3sFile) errors.push("f3s is required");
570
+
571
+ if (ttfFile) {
572
+ for (const message of lib.validateEmojisFont(ttfFile.buffer)) {
573
+ errors.push(message);
574
+ }
575
+ }
576
+
577
+ if (errors.length > 0) {
578
+ res.status(400).json({ errors: errors });
579
+ return;
580
+ }
581
+
582
+ // writes both payloads side by side, lazily creating the
583
+ // engraving subdirectory so a brand new deployment does
584
+ // not require any out of band initialization before the
585
+ // first font upload can land
586
+ const fontsDirectory = path.join(__dirname, "static", "fonts");
587
+ const f3sDirectory = path.join(fontsDirectory, "f3s", "fonts");
588
+ await fs.mkdir(f3sDirectory, { recursive: true });
589
+ await fs.writeFile(path.join(fontsDirectory, `${name}.ttf`), ttfFile.buffer);
590
+ await fs.writeFile(path.join(f3sDirectory, `${name}.f3s`), f3sFile.buffer);
591
+
592
+ res.json({ status: "ok", name: name });
593
+ }
594
+ clojure().catch(next);
595
+ });
596
+
597
+ app.post("/settings/fonts/:name/delete", lib.requireAdmin, (req, res, next) => {
598
+ async function clojure() {
599
+ const name = req.params.name;
600
+ if (!lib.FONT_NAME_PATTERN.test(name)) {
601
+ res.status(400).json({ error: "invalid font name" });
602
+ return;
603
+ }
604
+ if (EMOJIS_OWNED_FONTS.has(name)) {
605
+ res.status(400).json({ error: "name is reserved by the Emojis tab" });
606
+ return;
607
+ }
608
+
609
+ // removes both halves of the font pair, treating missing
610
+ // files as already deleted so a re-run cleans up any
611
+ // partial state left behind by a previous failure
612
+ const fontsDirectory = path.join(__dirname, "static", "fonts");
613
+ const targets = [
614
+ path.join(fontsDirectory, `${name}.ttf`),
615
+ path.join(fontsDirectory, "f3s", "fonts", `${name}.f3s`)
616
+ ];
617
+ let removed = false;
618
+ for (const target of targets) {
619
+ try {
620
+ await fs.unlink(target);
621
+ removed = true;
622
+ } catch (err) {
623
+ // ignores files that do not exist on disk
624
+ }
625
+ }
626
+
627
+ if (!removed) {
628
+ res.status(404).json({ error: "font not found" });
629
+ return;
630
+ }
631
+
632
+ res.json({ status: "ok" });
633
+ }
634
+ clojure().catch(next);
635
+ });
636
+
637
+ app.get("/settings/fonts/resolve", (req, res, next) => {
638
+ async function clojure() {
639
+ const names = typeof req.query.names === "string" ? req.query.names : "";
640
+ const requested = names
641
+ .split(",")
642
+ .map(value => value.trim())
643
+ .filter(Boolean)
644
+ .slice(0, 128);
645
+
646
+ // resolves each requested name from either the emoji or the
647
+ // text font subdirectory, base64 encoding the payload that
648
+ // is found first so the caller can attach it to the gravo
649
+ // print payload as part of the `extra_fonts` envelope;
650
+ // names that do not match the filename pattern or that have
651
+ // no corresponding payload are silently dropped so the
652
+ // caller still receives a partial response without leaking
653
+ // the missing entries through an error
654
+ const fontsDirectory = path.join(__dirname, "static", "fonts");
655
+ const candidates = [
656
+ path.join(fontsDirectory, "f3s", "emoji"),
657
+ path.join(fontsDirectory, "f3s", "fonts")
658
+ ];
659
+ const fonts = {};
660
+ for (const name of requested) {
661
+ const filename = `${name}.f3s`;
662
+ if (!lib.EMOJI_F3S_FILENAME_PATTERN.test(filename)) continue;
663
+ for (const directory of candidates) {
664
+ const candidatePath = path.join(directory, filename);
665
+ try {
666
+ const buffer = await fs.readFile(candidatePath);
667
+ fonts[name] = buffer.toString("base64");
668
+ break;
669
+ } catch (err) {
670
+ // not found at this candidate, keep walking
671
+ }
672
+ }
673
+ }
674
+ res.json({ fonts: fonts });
675
+ }
676
+ clojure().catch(next);
677
+ });
678
+
303
679
  app.get("/welcome", (req, res, next) => {
304
680
  const fullscreen =
305
681
  req.query.fullscreen !== undefined
@@ -317,6 +693,7 @@ app.get("/welcome", (req, res, next) => {
317
693
  masterb64: masterb64,
318
694
  config: req.session.config || {},
319
695
  showOptions: req.session.show_options !== "0",
696
+ viewportMode: req.session.viewport_mode === "store" ? "store" : "technical",
320
697
  info: info || {},
321
698
  user: req.session.user || null
322
699
  });
@@ -396,12 +773,23 @@ app.get("/report", (req, res, next) => {
396
773
  app.get("/console", (req, res, next) => {
397
774
  const theme = req.query.theme || req.session.theme || "";
398
775
  const locale = req.query.locale || req.session.locale || "";
776
+ req.session.theme = theme;
399
777
  req.session.locale = locale;
400
778
  res.render("console" + (locale ? `-${locale}` : ""), {
401
779
  theme: theme
402
780
  });
403
781
  });
404
782
 
783
+ app.get("/components", (req, res, next) => {
784
+ const theme = req.query.theme || req.session.theme || "";
785
+ const locale = req.query.locale || req.session.locale || "";
786
+ req.session.theme = theme;
787
+ req.session.locale = locale;
788
+ res.render("components", {
789
+ theme: theme
790
+ });
791
+ });
792
+
405
793
  app.get("/receipt", (req, res, next) => {
406
794
  async function clojure() {
407
795
  const locale = req.query.locale || req.session.locale || "";
@@ -1,7 +1,15 @@
1
1
  const util = require("hive-js-util");
2
2
  const yonius = require("yonius");
3
3
 
4
- const conf = {};
4
+ // values needed at module load time (before `start()` runs the
5
+ // async yonius load) are resolved directly from `process.env` so
6
+ // consumers like the express middleware mount can read them at
7
+ // require time; everything else is populated by `startConfig()`
8
+ const SESSION_MAX_AGE_DEFAULT = 6 * 30 * 24 * 60 * 60 * 1000; // ~6 months in ms
9
+ const conf = {
10
+ SESSION_SECRET: (process.env.SESSION_SECRET || "signatur").split(","),
11
+ SESSION_MAX_AGE: Number.parseInt(process.env.SESSION_MAX_AGE, 10) || SESSION_MAX_AGE_DEFAULT
12
+ };
5
13
 
6
14
  const FEATURES = {
7
15
  calligraphy: { env: "FEATURE_CALLIGRAPHY", defaultValue: false },
@@ -0,0 +1,104 @@
1
+ // matches the first four bytes of a TrueType font, the
2
+ // signature that every regular `coolemojis.ttf` payload
3
+ // must carry on its initial bytes for the browser font
4
+ // loader to recognise it
5
+ const TTF_MAGIC = Buffer.from([0x00, 0x01, 0x00, 0x00]);
6
+
7
+ // matches the first four bytes of an OpenType font carrying
8
+ // a CFF outline, accepted alongside the TrueType signature
9
+ // so that fonts authored in either format can replace the
10
+ // existing emoji set
11
+ const OTF_MAGIC = Buffer.from([0x4f, 0x54, 0x54, 0x4f]);
12
+
13
+ // matches the validated filename pattern accepted by the
14
+ // emoji `.f3s` upload endpoint, allowing lowercase
15
+ // alphanumeric segments separated by hyphens or dots so the
16
+ // existing `1101.coracao` style names from the bundled mapping
17
+ // can keep working alongside a hyphenated form
18
+ const EMOJI_F3S_FILENAME_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*\.f3s$/;
19
+
20
+ // matches the validated identifier pattern accepted by the
21
+ // text font upload endpoint, allowing only lowercase
22
+ // alphanumeric segments separated by hyphens so the resulting
23
+ // `.ttf` and `.f3s` filenames stay deterministic on disk
24
+ const FONT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+
26
+ /**
27
+ * Validates that the provided buffer starts with one of the
28
+ * accepted font magic byte sequences, returning an error
29
+ * message list compatible with the rest of the validators.
30
+ *
31
+ * @param {Buffer} buffer The font payload to inspect.
32
+ * @returns {Array} An array of error messages (empty if valid).
33
+ */
34
+ const validateEmojisFont = buffer => {
35
+ const errors = [];
36
+
37
+ if (!Buffer.isBuffer(buffer)) {
38
+ return ["font payload must be a buffer"];
39
+ }
40
+
41
+ // refuses payloads shorter than the four byte font signature
42
+ // so the magic comparison below cannot run against truncated
43
+ // uploads that would otherwise silently fail to load
44
+ if (buffer.length < 4) {
45
+ errors.push("font payload is too short to be a TTF or OTF file");
46
+ return errors;
47
+ }
48
+
49
+ const head = buffer.slice(0, 4);
50
+ if (!head.equals(TTF_MAGIC) && !head.equals(OTF_MAGIC)) {
51
+ errors.push("font must be a TTF or OTF file");
52
+ }
53
+
54
+ return errors;
55
+ };
56
+
57
+ /**
58
+ * Validates that the provided JSON text parses into a flat
59
+ * object of string to string entries matching the shape of
60
+ * the bundled `coolemojis.mapping.json` so that the existing
61
+ * viewport consumer keeps working after the upload.
62
+ *
63
+ * @param {String} text The mapping payload to inspect.
64
+ * @returns {Array} An array of error messages (empty if valid).
65
+ */
66
+ const validateEmojisMapping = text => {
67
+ const errors = [];
68
+
69
+ if (typeof text !== "string") {
70
+ return ["mapping payload must be a string"];
71
+ }
72
+
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ } catch (err) {
77
+ errors.push("mapping must be valid JSON");
78
+ return errors;
79
+ }
80
+
81
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
82
+ errors.push("mapping must be a plain object");
83
+ return errors;
84
+ }
85
+
86
+ // walks every entry and rejects non string values upfront so
87
+ // a malformed mapping cannot land on disk and break the
88
+ // downstream viewport lookup that expects character to name
89
+ // pairs for every glyph in the emoji catalog
90
+ for (const key of Object.keys(parsed)) {
91
+ if (typeof parsed[key] !== "string") {
92
+ errors.push(`mapping entry "${key}" must be a string`);
93
+ }
94
+ }
95
+
96
+ return errors;
97
+ };
98
+
99
+ module.exports = {
100
+ EMOJI_F3S_FILENAME_PATTERN: EMOJI_F3S_FILENAME_PATTERN,
101
+ FONT_NAME_PATTERN: FONT_NAME_PATTERN,
102
+ validateEmojisFont: validateEmojisFont,
103
+ validateEmojisMapping: validateEmojisMapping
104
+ };
package/lib/util/index.js CHANGED
@@ -2,6 +2,7 @@ const auth = require("./auth");
2
2
  const base = require("./base");
3
3
  const config = require("./config");
4
4
  const date = require("./date");
5
+ const emojis = require("./emojis");
5
6
  const errors = require("./errors");
6
7
  const locale = require("./locale");
7
8
  const profile = require("./profile");
@@ -10,6 +11,7 @@ Object.assign(module.exports, auth);
10
11
  Object.assign(module.exports, base);
11
12
  Object.assign(module.exports, config);
12
13
  Object.assign(module.exports, date);
14
+ Object.assign(module.exports, emojis);
13
15
  Object.assign(module.exports, errors);
14
16
  Object.assign(module.exports, locale);
15
17
  Object.assign(module.exports, profile);