mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated
# Conflicts: # prisma/schema.prisma
This commit is contained in:
commit
9ff39b5846
16 files changed
+1490
-41
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripImageMetadata } from "@/lib/clinician-share/exif-strip";
|
||||
|
||||
/**
|
||||
* The stripper walks container structure and drops metadata carriers without
|
||||
* decoding pixels. These fixtures are hand-built minimal-but-valid segment
|
||||
* streams so we can assert exactly which markers survive.
|
||||
*/
|
||||
|
||||
function u16be(n: number): Buffer {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(n);
|
||||
return b;
|
||||
}
|
||||
function u32be(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
}
|
||||
function u32le(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(n);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A JPEG segment: FF <marker> <len BE incl. the 2 len bytes> <payload>. */
|
||||
function jpegSegment(marker: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from([0xff, marker]),
|
||||
u16be(payload.length + 2),
|
||||
payload,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("stripImageMetadata — passthrough", () => {
|
||||
it("leaves non-strippable types byte-for-byte unchanged", () => {
|
||||
const pdf = Buffer.from("%PDF-1.4 fake pdf body");
|
||||
expect(stripImageMetadata(pdf, "application/pdf")).toBe(pdf);
|
||||
const tiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]);
|
||||
expect(stripImageMetadata(tiff, "image/tiff")).toBe(tiff);
|
||||
const heic = Buffer.from("ftyp-heic-fake");
|
||||
expect(stripImageMetadata(heic, "image/heic")).toBe(heic);
|
||||
});
|
||||
|
||||
it("returns malformed input for the claimed type unchanged (fail-safe)", () => {
|
||||
const notJpeg = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
expect(stripImageMetadata(notJpeg, "image/jpeg")).toBe(notJpeg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — JPEG", () => {
|
||||
// SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan.
|
||||
const soi = Buffer.from([0xff, 0xd8]);
|
||||
const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0"));
|
||||
const exifPayload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0"),
|
||||
// A recognisable GPS marker string we can assert is gone.
|
||||
Buffer.from("GPSLatitude=48.137 GPSLongitude=11.575"),
|
||||
]);
|
||||
const app1 = jpegSegment(0xe1, exifPayload);
|
||||
const iccPayload = Buffer.concat([
|
||||
Buffer.from("ICC_PROFILE\0"),
|
||||
Buffer.from([1, 2, 3, 4]),
|
||||
]);
|
||||
const app2 = jpegSegment(0xe2, iccPayload);
|
||||
const com = jpegSegment(0xfe, Buffer.from("private comment"));
|
||||
const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03]));
|
||||
const sosAndScan = Buffer.concat([
|
||||
Buffer.from([0xff, 0xda]),
|
||||
u16be(2 + 6),
|
||||
Buffer.from([0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]),
|
||||
Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]), // entropy-coded scan bytes
|
||||
]);
|
||||
const jpeg = Buffer.concat([soi, app0, app1, app2, com, dqt, sosAndScan]);
|
||||
|
||||
it("removes EXIF/GPS, XMP-carrying APP1, and the comment segment", () => {
|
||||
const out = stripImageMetadata(jpeg, "image/jpeg");
|
||||
expect(out.includes(Buffer.from("Exif\0\0"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("private comment"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps JFIF (APP0), ICC (APP2), the quantisation table, and the scan data", () => {
|
||||
const out = stripImageMetadata(jpeg, "image/jpeg");
|
||||
expect(out.subarray(0, 2)).toEqual(soi);
|
||||
expect(out.includes(Buffer.from("JFIF"))).toBe(true);
|
||||
expect(out.includes(Buffer.from("ICC_PROFILE"))).toBe(true);
|
||||
// The DQT segment and the entropy-coded scan tail must survive verbatim.
|
||||
expect(out.includes(dqt)).toBe(true);
|
||||
expect(out.includes(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]))).toBe(true);
|
||||
// Output is strictly smaller (metadata removed).
|
||||
expect(out.length).toBeLessThan(jpeg.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — PNG", () => {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
u32be(data.length),
|
||||
Buffer.from(type, "ascii"),
|
||||
data,
|
||||
u32be(0),
|
||||
]);
|
||||
}
|
||||
const ihdr = pngChunk(
|
||||
"IHDR",
|
||||
Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]),
|
||||
);
|
||||
const exif = pngChunk("eXIf", Buffer.from("GPSInfo private-location"));
|
||||
const text = pngChunk("tEXt", Buffer.from("Comment\0secret note"));
|
||||
const itxt = pngChunk(
|
||||
"iTXt",
|
||||
Buffer.from("XML:com.adobe.xmp\0\0\0\0\0<x:xmpmeta/>"),
|
||||
);
|
||||
const gama = pngChunk("gAMA", Buffer.from([0, 0, 0x8a, 0x3d]));
|
||||
const idat = pngChunk("IDAT", Buffer.from([0x78, 0x9c, 0x01, 0x00]));
|
||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
||||
const png = Buffer.concat([sig, ihdr, exif, text, itxt, gama, idat, iend]);
|
||||
|
||||
it("removes eXIf, tEXt, and iTXt (XMP) chunks", () => {
|
||||
const out = stripImageMetadata(png, "image/png");
|
||||
expect(out.includes(Buffer.from("GPSInfo"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("secret note"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps IHDR, gAMA, IDAT, IEND and the signature", () => {
|
||||
const out = stripImageMetadata(png, "image/png");
|
||||
expect(out.subarray(0, 8)).toEqual(sig);
|
||||
expect(out.includes(gama)).toBe(true);
|
||||
expect(out.includes(idat)).toBe(true);
|
||||
expect(out.includes(iend)).toBe(true);
|
||||
expect(out.length).toBeLessThan(png.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — WebP", () => {
|
||||
function webpChunk(fourcc: string, data: Buffer): Buffer {
|
||||
const padded =
|
||||
data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data;
|
||||
return Buffer.concat([
|
||||
Buffer.from(fourcc, "ascii"),
|
||||
u32le(data.length),
|
||||
padded,
|
||||
]);
|
||||
}
|
||||
// VP8X with EXIF (0x08) + XMP (0x04) flags set, an image chunk, plus EXIF/XMP.
|
||||
const vp8xFlags = Buffer.from([0x08 | 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const vp8x = webpChunk("VP8X", vp8xFlags);
|
||||
const vp8 = webpChunk("VP8 ", Buffer.from([0x11, 0x22, 0x33, 0x44]));
|
||||
const exif = webpChunk("EXIF", Buffer.from("GPSLocation private"));
|
||||
const xmp = webpChunk("XMP ", Buffer.from("<x:xmpmeta>secret</x:xmpmeta>"));
|
||||
const body = Buffer.concat([vp8x, vp8, exif, xmp]);
|
||||
const header = Buffer.concat([
|
||||
Buffer.from("RIFF", "ascii"),
|
||||
u32le(4 + body.length),
|
||||
Buffer.from("WEBP", "ascii"),
|
||||
]);
|
||||
const webp = Buffer.concat([header, body]);
|
||||
|
||||
it("removes the EXIF and XMP chunks", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.includes(Buffer.from("GPSLocation"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the image chunk and clears the VP8X EXIF/XMP flag bits", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.includes(Buffer.from([0x11, 0x22, 0x33, 0x44]))).toBe(true);
|
||||
// Locate VP8X and check its flag byte: EXIF/XMP bits cleared.
|
||||
const vp8xIdx = out.indexOf(Buffer.from("VP8X", "ascii"));
|
||||
expect(vp8xIdx).toBeGreaterThan(0);
|
||||
const flags = out[vp8xIdx + 8];
|
||||
expect(flags & 0x08).toBe(0);
|
||||
expect(flags & 0x04).toBe(0);
|
||||
});
|
||||
|
||||
it("rewrites the RIFF size header to match the trimmed body", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.toString("ascii", 0, 4)).toBe("RIFF");
|
||||
expect(out.toString("ascii", 8, 12)).toBe("WEBP");
|
||||
// Declared size == 4 (for "WEBP") + the remaining chunk bytes.
|
||||
expect(out.readUInt32LE(4)).toBe(out.length - 8);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,21 @@ vi.mock("@/lib/doctor-report-data", () => ({
|
||||
vi.mock("@/lib/validations/doctor-report-prefs", () => ({
|
||||
parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
clinicianShareLinkDocument: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { loadShareViewData } from "../share-view-data";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ShareContext } from "../resolve-share-token";
|
||||
|
||||
const collect = collectDoctorReportData as ReturnType<typeof vi.fn>;
|
||||
const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
function ctx(overrides: Partial<ShareContext> = {}): ShareContext {
|
||||
return {
|
||||
@@ -44,6 +53,7 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
collect.mockResolvedValue({
|
||||
patient: { displayName: "Shared record" },
|
||||
});
|
||||
findDocs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("scopes the aggregator to the OWNER from the share context, never the wire", async () => {
|
||||
@@ -71,6 +81,63 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
expect(patient).not.toHaveProperty("kvnr");
|
||||
});
|
||||
|
||||
it("lists the frozen document set as metadata only (never bytes)", async () => {
|
||||
findDocs.mockResolvedValue([
|
||||
{
|
||||
document: {
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: new Date("2026-01-15T00:00:00Z"),
|
||||
byteSize: 12345,
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
document: {
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
mimeType: "application/msword",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { documents } = await loadShareViewData(ctx());
|
||||
|
||||
// Scoped to THIS link + owner + live rows; the blob column is never named.
|
||||
const arg = findDocs.mock.calls[0]![0] as {
|
||||
where: Record<string, unknown>;
|
||||
select: { document: { select: Record<string, boolean> } };
|
||||
};
|
||||
expect(arg.where).toEqual({
|
||||
shareLinkId: "link-1",
|
||||
document: { userId: "owner-1", deletedAt: null },
|
||||
});
|
||||
expect(arg.select.document.select).not.toHaveProperty("contentEncrypted");
|
||||
|
||||
expect(documents).toEqual([
|
||||
{
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: "2026-01-15",
|
||||
byteSize: 12345,
|
||||
servingClass: "inline",
|
||||
},
|
||||
{
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
servingClass: "attachment",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => {
|
||||
const now = Date.now();
|
||||
await loadShareViewData(ctx({ rangeEnd: null }));
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.28 (document vault, Phase 3) — dependency-free metadata stripper for the
|
||||
* share egress boundary (P3-D4).
|
||||
*
|
||||
* A recipient of a shared document should not inadvertently receive the
|
||||
* owner's camera EXIF / GPS / XMP metadata. This module removes those markers
|
||||
* from the common camera-photo raster formats (JPEG, PNG, WebP) as the bytes
|
||||
* are served through the share route — NEVER at rest. The owner's stored
|
||||
* original is the artefact and is untouched (v1 §6.5): the strip is a pure
|
||||
* transform applied to a decrypted copy on its way out.
|
||||
*
|
||||
* Pure JS, no `sharp` / `libvips` / native decode (repo guardrail G6). We do
|
||||
* not decode or re-encode pixels: we walk the container's segment/chunk
|
||||
* structure and drop exactly the metadata carriers, copying every image-data
|
||||
* segment through verbatim. Output renders identically; only the metadata is
|
||||
* gone.
|
||||
*
|
||||
* Honest limits (documented in the operator docs + the share copy): PDF, TIFF,
|
||||
* HEIC/HEIF, and Office formats are out of this stripper's reach and pass
|
||||
* through unchanged. Those leave the origin as attachment-class downloads.
|
||||
* Any format we don't recognise — or any parse that runs off the end of the
|
||||
* buffer — is returned byte-for-byte unchanged (fail-safe: never corrupt a
|
||||
* document we can't confidently rewrite).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/** MIME types this stripper actively rewrites. Everything else passes through. */
|
||||
const STRIPPABLE_MIMES: ReadonlySet<string> = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strip EXIF/XMP/GPS metadata from a raster image's bytes, dispatched by the
|
||||
* document's stored (magic-byte-sniffed) MIME type. Returns the original
|
||||
* buffer unchanged for any non-strippable type, and — fail-safe — for any
|
||||
* input that does not parse cleanly as the claimed format.
|
||||
*/
|
||||
export function stripImageMetadata(bytes: Buffer, mimeType: string): Buffer {
|
||||
if (!STRIPPABLE_MIMES.has(mimeType)) return bytes;
|
||||
try {
|
||||
switch (mimeType) {
|
||||
case "image/jpeg":
|
||||
return stripJpeg(bytes);
|
||||
case "image/png":
|
||||
return stripPng(bytes);
|
||||
case "image/webp":
|
||||
return stripWebp(bytes);
|
||||
default:
|
||||
return bytes;
|
||||
}
|
||||
} catch {
|
||||
// Never corrupt a document because the stripper tripped: on any parse
|
||||
// error, serve the original bytes. The serving-class posture (nosniff +
|
||||
// true type) is the security boundary; the strip is a privacy nicety.
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JPEG ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A JPEG is `FFD8` (SOI) followed by marker segments. Each non-entropy marker
|
||||
// is `FF <marker> <len-hi> <len-lo> <payload…>` where `len` counts the two
|
||||
// length bytes + payload. We drop the metadata-bearing APP segments and the
|
||||
// comment segment, copy every other segment verbatim, and once we hit SOS
|
||||
// (`FFDA`) copy the rest of the file (entropy-coded scan data) unchanged.
|
||||
//
|
||||
// Dropped: APP1 (`FFE1`, Exif + XMP), APP13 (`FFED`, Photoshop / IPTC), and
|
||||
// COM (`FFFE`, free-text comment). Kept: APP0 (`FFE0`, JFIF — some decoders
|
||||
// expect it) and APP2 (`FFE2`, ICC colour profile — dropping it would shift
|
||||
// colours), plus every structural segment (quantisation/Huffman tables, frame
|
||||
// headers, …).
|
||||
|
||||
const JPEG_DROP_MARKERS: ReadonlySet<number> = new Set([
|
||||
0xe1, // APP1 — Exif, XMP
|
||||
0xed, // APP13 — Photoshop / IPTC-IIM
|
||||
0xfe, // COM — comment
|
||||
]);
|
||||
|
||||
function stripJpeg(bytes: Buffer): Buffer {
|
||||
if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
||||
return bytes; // not a JPEG SOI — pass through
|
||||
}
|
||||
const out: Buffer[] = [bytes.subarray(0, 2)]; // SOI
|
||||
let offset = 2;
|
||||
while (offset + 1 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
// Misaligned — bail and keep the remainder verbatim (fail-safe).
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
const marker = bytes[offset + 1];
|
||||
// Start Of Scan: everything after is entropy-coded image data. Copy it all.
|
||||
if (marker === 0xda) {
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
// Standalone markers without a length payload (RSTn, TEM) — copy the 2
|
||||
// marker bytes and continue.
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
out.push(bytes.subarray(offset, offset + 2));
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
// Length-prefixed segment.
|
||||
if (offset + 4 > bytes.length) {
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
const segLen = bytes.readUInt16BE(offset + 2);
|
||||
const segEnd = offset + 2 + segLen;
|
||||
if (segLen < 2 || segEnd > bytes.length) {
|
||||
// Corrupt length — keep the remainder verbatim rather than lose data.
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
if (!JPEG_DROP_MARKERS.has(marker)) {
|
||||
out.push(bytes.subarray(offset, segEnd));
|
||||
}
|
||||
offset = segEnd;
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
|
||||
// ─── PNG ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An 8-byte signature followed by `length(4 BE) type(4 ascii) data crc(4)`
|
||||
// chunks. We drop the ancillary metadata chunks and copy everything else
|
||||
// (IHDR, PLTE, IDAT, IEND, gAMA, cHRM, iCCP, sRGB, …) verbatim.
|
||||
//
|
||||
// Dropped: `eXIf` (EXIF), `tEXt` / `zTXt` / `iTXt` (text incl. XMP), `tIME`
|
||||
// (last-modified timestamp).
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
|
||||
const PNG_DROP_CHUNKS: ReadonlySet<string> = new Set([
|
||||
"eXIf",
|
||||
"tEXt",
|
||||
"zTXt",
|
||||
"iTXt",
|
||||
"tIME",
|
||||
]);
|
||||
|
||||
function stripPng(bytes: Buffer): Buffer {
|
||||
if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||
return bytes;
|
||||
}
|
||||
const out: Buffer[] = [bytes.subarray(0, 8)];
|
||||
let offset = 8;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const dataLen = bytes.readUInt32BE(offset);
|
||||
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
||||
const chunkEnd = offset + 12 + dataLen; // len + type + data + crc
|
||||
if (chunkEnd > bytes.length) {
|
||||
// Truncated chunk — keep the remainder verbatim (fail-safe).
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
if (!PNG_DROP_CHUNKS.has(type)) {
|
||||
out.push(bytes.subarray(offset, chunkEnd));
|
||||
}
|
||||
offset = chunkEnd;
|
||||
if (type === "IEND") break; // nothing legitimate follows IEND
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
|
||||
// ─── WebP ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A RIFF container: `"RIFF" <size LE> "WEBP"` then FourCC chunks
|
||||
// (`<fourcc(4)> <size LE(4)> <payload…>`, payload padded to an even length).
|
||||
// We drop the `EXIF` and `XMP ` chunks and, when the extended header `VP8X` is
|
||||
// present, clear its EXIF/XMP presence flag bits so the rewritten file is
|
||||
// self-consistent. The image-data chunks (VP8/VP8L/VP8X/ALPH/ANIM/ANMF) copy
|
||||
// through verbatim.
|
||||
|
||||
function stripWebp(bytes: Buffer): Buffer {
|
||||
if (
|
||||
bytes.length < 12 ||
|
||||
bytes.toString("ascii", 0, 4) !== "RIFF" ||
|
||||
bytes.toString("ascii", 8, 12) !== "WEBP"
|
||||
) {
|
||||
return bytes;
|
||||
}
|
||||
const kept: Buffer[] = [];
|
||||
let offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const fourcc = bytes.toString("ascii", offset, offset + 4);
|
||||
const chunkSize = bytes.readUInt32LE(offset + 4);
|
||||
const padded = chunkSize + (chunkSize % 2); // chunks pad to even length
|
||||
const payloadStart = offset + 8;
|
||||
const chunkEnd = payloadStart + padded;
|
||||
if (chunkEnd > bytes.length) {
|
||||
// Truncated — keep the remainder verbatim (fail-safe).
|
||||
kept.push(bytes.subarray(offset));
|
||||
offset = bytes.length;
|
||||
break;
|
||||
}
|
||||
if (fourcc === "EXIF" || fourcc === "XMP ") {
|
||||
// Drop the metadata chunk entirely.
|
||||
offset = chunkEnd;
|
||||
continue;
|
||||
}
|
||||
if (fourcc === "VP8X") {
|
||||
// Extended-format header: byte 0 of the payload carries the feature
|
||||
// flags. Clear the EXIF (0x08) and XMP (0x04) presence bits so the
|
||||
// header no longer advertises metadata we just removed.
|
||||
const chunk = Buffer.from(bytes.subarray(offset, chunkEnd));
|
||||
chunk[8] = chunk[8] & ~0x08 & ~0x04;
|
||||
kept.push(chunk);
|
||||
offset = chunkEnd;
|
||||
continue;
|
||||
}
|
||||
kept.push(bytes.subarray(offset, chunkEnd));
|
||||
offset = chunkEnd;
|
||||
}
|
||||
// Rebuild the RIFF header with the recomputed payload size (the sum of the
|
||||
// kept chunks plus the trailing "WEBP" FourCC).
|
||||
const body = Buffer.concat(kept);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write("RIFF", 0, "ascii");
|
||||
header.writeUInt32LE(4 + body.length, 4); // "WEBP" + chunk bytes
|
||||
header.write("WEBP", 8, "ascii");
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
@@ -12,19 +12,40 @@
|
||||
* them under an explicit "not a clinical assessment" card), but they are read
|
||||
* straight from the aggregator — no AI call, no coach, no insight generation.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
type DoctorReportData,
|
||||
type DoctorReportRange,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
|
||||
import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
|
||||
|
||||
/**
|
||||
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
|
||||
* bytes: the share serve route (`/c/<token>/d/<id>`) is the only decrypt path
|
||||
* (P3-D5). The recipient view renders this list and points each entry at that
|
||||
* route (Class A inline preview / Class B download).
|
||||
*/
|
||||
export interface ShareViewDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
kind: string;
|
||||
/** Filing date (YYYY-MM-DD at UTC) or null. */
|
||||
documentDate: string | null;
|
||||
byteSize: number;
|
||||
servingClass: DocumentServingClass;
|
||||
}
|
||||
|
||||
export interface ShareViewData {
|
||||
/** The aggregated, owner-scoped report payload over the frozen window. */
|
||||
report: DoctorReportData;
|
||||
/** The resolved section toggles (mood opt-in, defaults otherwise). */
|
||||
sections: ReturnType<typeof parseDoctorReportPrefs>;
|
||||
/** v1.28 — the hand-picked documents on this link (metadata only). */
|
||||
documents: ShareViewDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +74,51 @@ export async function loadShareViewData(
|
||||
const sections = parseDoctorReportPrefs(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
const report = await collectDoctorReportData(context.ownerUserId, range, {
|
||||
sections,
|
||||
const [report, documents] = await Promise.all([
|
||||
collectDoctorReportData(context.ownerUserId, range, { sections }),
|
||||
loadShareDocuments(context),
|
||||
]);
|
||||
|
||||
return { report, sections, documents };
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen document set for a resolved share, as metadata only. Scoped to
|
||||
* the link's membership rows AND the owner (defence in depth) AND live rows —
|
||||
* a document the owner soft-deleted after sharing drops out of the list, just
|
||||
* as it 404s at the serve route. The blob column is never selected.
|
||||
*/
|
||||
async function loadShareDocuments(
|
||||
context: ShareContext,
|
||||
): Promise<ShareViewDocument[]> {
|
||||
const rows = await prisma.clinicianShareLinkDocument.findMany({
|
||||
where: {
|
||||
shareLinkId: context.shareLinkId,
|
||||
document: { userId: context.ownerUserId, deletedAt: null },
|
||||
},
|
||||
select: {
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
kind: true,
|
||||
documentDate: true,
|
||||
byteSize: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return { report, sections };
|
||||
return rows.map(({ document }) => ({
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
kind: document.kind,
|
||||
documentDate: document.documentDate
|
||||
? document.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
byteSize: document.byteSize,
|
||||
servingClass: servingClassFor(document.mimeType),
|
||||
}));
|
||||
}
|
||||
@@ -25,6 +25,9 @@ const shareLinkSummary = z.object({
|
||||
// v1.18.7 — whether a passphrase second factor guards this link. Always true
|
||||
// for links created on v1.18.7+; false only for legacy links.
|
||||
protected: z.boolean(),
|
||||
// v1.28 — how many documents this share carries. Never the ids, never the
|
||||
// bytes; the share serve route is the only decrypt path.
|
||||
documentCount: z.number(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
@@ -103,7 +106,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Export"],
|
||||
summary: "Create a clinician share link (v1.11.0)",
|
||||
description:
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/c/{token}/d/{id}": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Serve a shared document blob (v1.28, public)",
|
||||
description:
|
||||
"Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z.string().describe("Raw `hls_` share token."),
|
||||
id: z.string().describe("A document id on the link's frozen set."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` for Class B (attachment).",
|
||||
content: {
|
||||
"application/octet-stream": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the frozen set).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description: "Per-link serve rate limit exceeded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/metadata": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
|
||||
@@ -33,10 +33,22 @@ export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
|
||||
|
||||
const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.28 — the most documents a single share may carry. A share is a hand-picked
|
||||
* handover, not a bulk dump; the cap bounds the create-time ownership check and
|
||||
* the public view's per-blob fetch fan-out. The ids are validated as the
|
||||
* caller's own LIVE documents in the route (a schema can only bound the shape).
|
||||
*/
|
||||
export const SHARE_LINK_MAX_DOCUMENTS = 50;
|
||||
|
||||
/**
|
||||
* Create payload. `expiresAt` is an absolute ISO instant, must be in the
|
||||
* future, and at most `SHARE_LINK_MAX_DAYS` ahead of now. `rangeStart` /
|
||||
* `rangeEnd` are the frozen reporting window (rangeEnd null = rolling).
|
||||
* `documentIds` (optional) is the hand-picked document set, frozen write-once
|
||||
* at create; each id is ownership-checked in the route before any row is
|
||||
* written. A documents-only share (empty report sections + a non-empty
|
||||
* `documentIds`) is valid.
|
||||
*/
|
||||
export const createShareLinkSchema = z
|
||||
.object({
|
||||
@@ -46,6 +58,10 @@ export const createShareLinkSchema = z
|
||||
sections: exportSectionsSchema.optional(),
|
||||
resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(),
|
||||
allowFhirApi: z.boolean().optional(),
|
||||
documentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
.max(SHARE_LINK_MAX_DOCUMENTS)
|
||||
.optional(),
|
||||
expiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((v) => new Date(v).getTime() > Date.now(), {
|
||||
|
||||
Reference in new issue
Block a user