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 +56 -7
- package/app.js +392 -4
- package/lib/util/config.js +9 -1
- package/lib/util/emojis.js +104 -0
- package/lib/util/index.js +2 -0
- package/lib/util/profile.js +4 -0
- package/package.json +3 -2
- package/static/js/bundle.js +2019 -51
- package/static/js/main.js +41 -0
- package/static/js/plugins/emojis.js +265 -0
- package/static/js/plugins/fontsmanager.js +226 -0
- package/static/js/plugins/keyboard.js +11 -5
- package/static/js/plugins/modal.js +161 -6
- package/static/js/plugins/printjobs.js +1195 -0
- package/static/js/plugins/welcome.js +115 -38
- package/test/lib/smoke.js +29 -0
- package/test/lib/util/emojis.js +140 -0
- package/views/components.ejs +440 -0
- package/views/head.ejs +7 -0
- package/views/settings-pt_pt.ejs +84 -0
- package/views/settings.ejs +84 -0
- package/views/viewport-pt_pt.ejs +78 -0
- package/views/viewport.ejs +78 -0
- package/views/welcome-pt_pt.ejs +4 -1
- package/views/welcome.ejs +4 -1
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
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
27
|
-
//
|
|
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:
|
|
36
|
+
keys: lib.conf.SESSION_SECRET,
|
|
32
37
|
httpOnly: true,
|
|
33
|
-
maxAge:
|
|
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 || "";
|
package/lib/util/config.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
const util = require("hive-js-util");
|
|
2
2
|
const yonius = require("yonius");
|
|
3
3
|
|
|
4
|
-
|
|
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);
|