@7365admin1/layer-common 4.82.1-staging.472 → 4.82.1-staging.474
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.
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<v-col class="font-weight-bold">{{ label }}:</v-col>
|
|
20
20
|
|
|
21
21
|
<v-col cols="9" class="px-10">
|
|
22
|
-
<span v-if="key === 'content'" v-html="getFieldValue(key)" />
|
|
22
|
+
<span v-if="key === 'content'" v-html="sanitizeHtml(getFieldValue(key))" />
|
|
23
23
|
<span v-else-if="key === 'noExpiration'">
|
|
24
24
|
<BulletinExpirationChip
|
|
25
25
|
:value="getFieldValue(key) as boolean"
|
|
@@ -2331,11 +2331,59 @@ function applyFacialResult(
|
|
|
2331
2331
|
}
|
|
2332
2332
|
}
|
|
2333
2333
|
|
|
2334
|
+
/**
|
|
2335
|
+
* THE IDENTITY ROW BEHIND A ROSTER ROW.
|
|
2336
|
+
*
|
|
2337
|
+
* This list is read from the READER (`getReaderUsers`), and `listReaderUsers`
|
|
2338
|
+
* builds each row out of the device's own `users` columns - it carries no
|
|
2339
|
+
* identity `_id` at all, so `toHidUser` leaves `_id` undefined for every row on
|
|
2340
|
+
* screen. A delete that only acted when `_id` was present therefore never
|
|
2341
|
+
* touched the database: the device user was destroyed and the identity stayed
|
|
2342
|
+
* `active`, with no error, because the guard was an `if` rather than a failure.
|
|
2343
|
+
*
|
|
2344
|
+
* That left the row orphaned, and an orphan is not inert. `findIdentityBySubject`
|
|
2345
|
+
* excludes only `status: "deleted"`, so it still answers "this person is already
|
|
2346
|
+
* enrolled on this reader" and REFUSES to enrol somebody the operator can no
|
|
2347
|
+
* longer see. The reconcile reads it too: `findProfileIdentity` finds the active
|
|
2348
|
+
* row and reuses its stored `hidUserId`, so the device user is rebuilt with the
|
|
2349
|
+
* same UID the operator just deleted, from an access grant that was never
|
|
2350
|
+
* removed because `deleteIdentity` never ran.
|
|
2351
|
+
*
|
|
2352
|
+
* Resolved the same way `saveUser` already resolves it, by the reader's own keys.
|
|
2353
|
+
*/
|
|
2354
|
+
async function findIdentityIdForUser(readerId: string, user: HidUser) {
|
|
2355
|
+
if (user._id) return user._id;
|
|
2356
|
+
if (!readerId) return "";
|
|
2357
|
+
try {
|
|
2358
|
+
const identity = await findExistingIdentityOnReader(readerId, {
|
|
2359
|
+
hidUserId: String(user.hidUserId ?? ""),
|
|
2360
|
+
registration: String(user.registration ?? ""),
|
|
2361
|
+
cardNo: String(user.cardNo ?? ""),
|
|
2362
|
+
metadata: {},
|
|
2363
|
+
});
|
|
2364
|
+
return identity?._id ?? "";
|
|
2365
|
+
} catch (error: unknown) {
|
|
2366
|
+
// A lookup failure must not stop the device delete the operator asked for.
|
|
2367
|
+
// The row stays behind and the next delete can clear it.
|
|
2368
|
+
console.error(
|
|
2369
|
+
"Unable to resolve the stored identity for this HID user:",
|
|
2370
|
+
error
|
|
2371
|
+
);
|
|
2372
|
+
return "";
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2334
2376
|
async function deleteUser() {
|
|
2335
2377
|
if (!selectedUser.value) return;
|
|
2336
2378
|
|
|
2337
2379
|
deleting.value = true;
|
|
2338
2380
|
try {
|
|
2381
|
+
// Resolved BEFORE the device user is destroyed: this reads our own records,
|
|
2382
|
+
// and doing it first means a failure here costs nothing.
|
|
2383
|
+
const identityId = await findIdentityIdForUser(
|
|
2384
|
+
selectedReaderId.value,
|
|
2385
|
+
selectedUser.value
|
|
2386
|
+
);
|
|
2339
2387
|
await syncHidUserRole(
|
|
2340
2388
|
selectedReaderId.value,
|
|
2341
2389
|
toHidNumericId(selectedUser.value.hidUserId),
|
|
@@ -2346,8 +2394,10 @@ async function deleteUser() {
|
|
|
2346
2394
|
toHidNumericId(selectedUser.value.hidUserId)
|
|
2347
2395
|
);
|
|
2348
2396
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
2349
|
-
if (
|
|
2350
|
-
|
|
2397
|
+
if (identityId) {
|
|
2398
|
+
// Soft-deletes the row AND removes the access grant that let this person
|
|
2399
|
+
// through, which is what stops the next reconcile rebuilding them.
|
|
2400
|
+
await deleteIdentity(identityId);
|
|
2351
2401
|
}
|
|
2352
2402
|
deleteDialog.value = false;
|
|
2353
2403
|
await loadUsers();
|
package/nuxt.config.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
2
|
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
|
3
|
+
import { SECURITY_HEADERS } from "./utils/security-headers";
|
|
3
4
|
export default defineNuxtConfig({
|
|
4
5
|
ssr: false,
|
|
5
6
|
devtools: { enabled: false },
|
|
7
|
+
|
|
8
|
+
// Security headers for every app that extends this layer: the HTML shell gets
|
|
9
|
+
// them from Nitro, the static files from the `_headers` file the Cloudflare
|
|
10
|
+
// preset writes from these same rules. See utils/security-headers.ts.
|
|
11
|
+
routeRules: {
|
|
12
|
+
"/**": { headers: SECURITY_HEADERS },
|
|
13
|
+
// Stored files read with the signed-in session (API-core
|
|
14
|
+
// GET /api/files/object/<key>) instead of the public CDN. Unused until an
|
|
15
|
+
// app's API_DO_STORAGE_ENDPOINT points at /api/storage/<folder>; that switch
|
|
16
|
+
// goes with SPACES_PRIVATE_PREFIXES - see the storage runbook.
|
|
17
|
+
...(process.env.API_CORE
|
|
18
|
+
? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
|
|
19
|
+
: {}),
|
|
20
|
+
},
|
|
6
21
|
build: {
|
|
7
22
|
transpile: ["vuetify"],
|
|
8
23
|
},
|
|
@@ -48,6 +63,9 @@ export default defineNuxtConfig({
|
|
|
48
63
|
cookieConfig: {
|
|
49
64
|
domain: (process.env.DOMAIN as string) ?? "localhost",
|
|
50
65
|
secure: true,
|
|
66
|
+
// Explicit, not the browser default: the API also reads `sid` from the
|
|
67
|
+
// cookie, so a cross-site form post must never carry it (CSRF).
|
|
68
|
+
sameSite: "lax",
|
|
51
69
|
maxAge: 30 * 24 * 60 * 60,
|
|
52
70
|
},
|
|
53
71
|
APP: (process.env.APP as string) ?? "App",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.82.1-staging.
|
|
5
|
+
"version": "4.82.1-staging.474",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"pages",
|
|
23
23
|
"plugins",
|
|
24
24
|
"public",
|
|
25
|
+
"server",
|
|
25
26
|
"services",
|
|
26
27
|
"types",
|
|
27
28
|
"utils",
|
|
@@ -57,8 +58,9 @@
|
|
|
57
58
|
"@mdi/font": "^7.4.47",
|
|
58
59
|
"@types/qrcode": "^1.5.6",
|
|
59
60
|
"ckeditor5": "47.4.0",
|
|
61
|
+
"dompurify": "^3.3.1",
|
|
60
62
|
"hls.js": "^1.6.13",
|
|
61
|
-
"html2pdf.js": "^0.
|
|
63
|
+
"html2pdf.js": "^0.14.0",
|
|
62
64
|
"html5-qrcode": "^2.3.8",
|
|
63
65
|
"moment-timezone": "^0.6.0",
|
|
64
66
|
"nuxt-signature-pad": "^1.8.0",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Nuxt's renderer stamps `x-powered-by: Nuxt` on the HTML shell. It tells a
|
|
2
|
+
// scanner the framework for free and helps nobody, so drop it.
|
|
3
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
4
|
+
nitroApp.hooks.hook("render:response", (response) => {
|
|
5
|
+
if (response?.headers) delete response.headers["x-powered-by"];
|
|
6
|
+
});
|
|
7
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one way rich text typed by a user (announcements, facility descriptions,
|
|
5
|
+
* policies) reaches `v-html`. Keeps formatting, drops scripts, event handlers,
|
|
6
|
+
* `javascript:` links and embedded frames/forms. Browser-only (the apps are
|
|
7
|
+
* `ssr: false`); without a DOM it returns "" rather than unsanitised HTML.
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizeHtml(html: unknown): string {
|
|
10
|
+
if (html === null || html === undefined || html === "") return "";
|
|
11
|
+
if (typeof window === "undefined" || !DOMPurify.isSupported) return "";
|
|
12
|
+
return DOMPurify.sanitize(String(html), {
|
|
13
|
+
USE_PROFILES: { html: true },
|
|
14
|
+
FORBID_TAGS: ["style", "form", "input", "button", "iframe", "object", "embed"],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers every web app sends, on the HTML shell (Nitro route rules)
|
|
3
|
+
* and on the static files (nitro writes the same rules into Cloudflare's
|
|
4
|
+
* `_headers`). One list, so the eleven apps cannot drift apart.
|
|
5
|
+
*
|
|
6
|
+
* The ENFORCED Content-Security-Policy only carries the directives that cannot
|
|
7
|
+
* break a screen: who may frame us (clickjacking), no plugins, no <base> swap.
|
|
8
|
+
* The full policy ships as Report-Only first: the apps load Google Fonts,
|
|
9
|
+
* camera players and documents in iframes, sockets and the Spaces CDN, and a
|
|
10
|
+
* wrong guess there blanks a page. Violations show in the browser console;
|
|
11
|
+
* once a release runs clean, move it to the enforced header.
|
|
12
|
+
*
|
|
13
|
+
* HSTS deliberately has no `preload`: preload is decided for the whole
|
|
14
|
+
* iservice365.org domain at the apex and is hard to undo (owner/leads action).
|
|
15
|
+
*/
|
|
16
|
+
export const CSP_ENFORCED = [
|
|
17
|
+
"frame-ancestors 'self'",
|
|
18
|
+
"object-src 'none'",
|
|
19
|
+
"base-uri 'self'",
|
|
20
|
+
].join("; ");
|
|
21
|
+
|
|
22
|
+
export const CSP_REPORT_ONLY = [
|
|
23
|
+
"default-src 'self'",
|
|
24
|
+
"script-src 'self'",
|
|
25
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
26
|
+
"font-src 'self' data: https://fonts.gstatic.com",
|
|
27
|
+
"img-src 'self' data: blob: https:",
|
|
28
|
+
"media-src 'self' blob: https:",
|
|
29
|
+
"connect-src 'self' https: wss:",
|
|
30
|
+
"frame-src 'self' https:",
|
|
31
|
+
"worker-src 'self' blob:",
|
|
32
|
+
"object-src 'none'",
|
|
33
|
+
"base-uri 'self'",
|
|
34
|
+
"frame-ancestors 'self'",
|
|
35
|
+
"form-action 'self' https:",
|
|
36
|
+
"upgrade-insecure-requests",
|
|
37
|
+
].join("; ");
|
|
38
|
+
|
|
39
|
+
export const SECURITY_HEADERS: Record<string, string> = {
|
|
40
|
+
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
|
41
|
+
"x-content-type-options": "nosniff",
|
|
42
|
+
"x-frame-options": "SAMEORIGIN",
|
|
43
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
44
|
+
// camera/mic: QR scan, photo capture; geolocation: attendance;
|
|
45
|
+
// usb/serial/hid: card readers (useWebUsb). Everything else is off.
|
|
46
|
+
"permissions-policy":
|
|
47
|
+
"camera=(self), microphone=(self), geolocation=(self), usb=(self), serial=(self), hid=(self), payment=(self), display-capture=(self), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=()",
|
|
48
|
+
// allow-popups: payment and document windows we open keep working.
|
|
49
|
+
"cross-origin-opener-policy": "same-origin-allow-popups",
|
|
50
|
+
"content-security-policy": CSP_ENFORCED,
|
|
51
|
+
"content-security-policy-report-only": CSP_REPORT_ONLY,
|
|
52
|
+
};
|