Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/demo-pro-seed/file-objects.ts
T

358 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { DeleteObjectsCommand, GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { loadRequiredWebConfig } from "@hivegrid/config";
import { buildFileStorageKeys } from "@hivegrid/storage";
import {
deleteFile,
hasStorageCredentials,
putBufferAsFile,
resolveStorageBinding,
resolveStorageBucket,
} from "../../apps/web/app/utils/integrations/storageService";
const DEFAULT_STORAGE_CONCURRENCY = 12;
const PDF_CONTENT_TYPE = "application/pdf";
const encoder = new TextEncoder();
export type SeedFileObjectDescriptor = {
id: string;
displayName: string;
notes: string;
};
export type SeedFileObjectMetadata = SeedFileObjectDescriptor & {
mimeType: string;
sizeBytes: number;
checksumSha256: string;
};
type PersistedSeedFileObject = SeedFileObjectDescriptor & {
mimeType: string;
sizeBytes: number;
checksumSha256: string;
};
type PutObject = (input: { bucket: string; key: string; body: Uint8Array; contentType?: string }) => Promise<unknown>;
type DeleteObject = (input: { bucket: string; key: string }) => Promise<unknown>;
type SeedS3Client = {
send(command: unknown): Promise<unknown>;
};
function printableAscii(value: string): string {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.replace(/[^\x20-\x7E]/gu, "?")
.replace(/\s+/gu, " ")
.trim();
}
function escapePdfLiteral(value: string): string {
return printableAscii(value).replace(/([\\()])/gu, "\\$1");
}
function wrapText(value: string, maxLength = 72): string[] {
const words = printableAscii(value).split(" ").filter(Boolean);
const lines: string[] = [];
let line = "";
for (const word of words) {
if (line.length === 0) {
line = word;
continue;
}
if (line.length + word.length + 1 <= maxLength) {
line += ` ${word}`;
continue;
}
lines.push(line);
line = word;
}
if (line.length > 0) lines.push(line);
return lines.length > 0 ? lines : ["Projektunterlage"];
}
function byteLength(value: string): number {
return encoder.encode(value).byteLength;
}
/** Builds a deterministic, single-page PDF whose xref offsets match its exact bytes. */
export function buildSeedPdfBytes(file: SeedFileObjectDescriptor): Uint8Array {
const title = escapePdfLiteral(file.displayName);
const detailLines = wrapText(file.notes).slice(0, 6).map(escapePdfLiteral);
const content = [
"BT",
"/F1 18 Tf",
"72 770 Td",
`(${title}) Tj`,
"/F1 11 Tf",
...detailLines.flatMap((line) => ["0 -24 Td", `(${line}) Tj`]),
"0 -36 Td",
`(Dokument-ID: ${escapePdfLiteral(file.id)}) Tj`,
"ET",
"",
].join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${byteLength(content)} >>\nstream\n${content}endstream`,
];
let pdf = "%PDF-1.4\n% HiveGrid generated staging document\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xrefOffset = byteLength(pdf);
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
pdf += offsets
.slice(1)
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`)
.join("");
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return encoder.encode(pdf);
}
export function buildSeedFileObjectMetadata(file: SeedFileObjectDescriptor): SeedFileObjectMetadata {
const bytes = buildSeedPdfBytes(file);
return {
...file,
mimeType: PDF_CONTENT_TYPE,
sizeBytes: bytes.byteLength,
checksumSha256: createHash("sha256").update(bytes).digest("hex"),
};
}
export function seedFileStorageKey(tenantId: string, fileId: string): string {
return buildFileStorageKeys(tenantId, fileId).original;
}
export function seedFileStoragePrefix(tenantId: string): string {
const probe = seedFileStorageKey(tenantId, "seed-prefix-probe");
return probe.slice(0, -"seed-prefix-probe/original".length);
}
function createSeedS3Client(): SeedS3Client {
const binding = resolveStorageBinding();
const { storage } = loadRequiredWebConfig();
if (binding.provider !== "s3" || !storage.endpoint || !storage.region || !storage.accessKeyId || !storage.secretAccessKey) {
throw new Error("demo-pro seed files: complete S3 staging storage configuration is required");
}
return new S3Client({
endpoint: storage.endpoint,
region: storage.region,
credentials: {
accessKeyId: storage.accessKeyId,
secretAccessKey: storage.secretAccessKey,
sessionToken: storage.sessionToken,
},
forcePathStyle: storage.forcePathStyle?.toLowerCase() === "true",
}) as unknown as SeedS3Client;
}
async function storageBodyBytes(body: unknown): Promise<Uint8Array> {
if (!body || typeof (body as { transformToByteArray?: unknown }).transformToByteArray !== "function") {
throw new Error("demo-pro seed files: storage object body is unreadable");
}
return (body as { transformToByteArray(): Promise<Uint8Array> }).transformToByteArray();
}
function assertConcurrency(concurrency: number): void {
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 64) {
throw new Error("demo-pro seed files: concurrency must be an integer between 1 and 64");
}
}
async function forEachConcurrent<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
assertConcurrency(concurrency);
let nextIndex = 0;
let failed = false;
let failure: unknown;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (!failed && nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
try {
await worker(items[index]!);
} catch (error) {
if (!failed) failure = error;
failed = true;
}
}
});
await Promise.all(workers);
if (failed) throw failure;
}
function assertMetadataMatches(file: PersistedSeedFileObject, bytes: Uint8Array): void {
const checksumSha256 = createHash("sha256").update(bytes).digest("hex");
if (file.mimeType !== PDF_CONTENT_TYPE || file.sizeBytes !== bytes.byteLength || file.checksumSha256 !== checksumSha256) {
throw new Error(`demo-pro seed files: generated PDF metadata drifted for ${file.id}`);
}
}
export async function uploadDemoProSeedFileObjects({
tenantId,
files,
concurrency = DEFAULT_STORAGE_CONCURRENCY,
bucket = resolveStorageBucket(),
putObject = putBufferAsFile,
}: {
tenantId: string;
files: readonly PersistedSeedFileObject[];
concurrency?: number;
bucket?: string;
putObject?: PutObject;
}): Promise<{ bucket: string; uploaded: number }> {
await forEachConcurrent(files, concurrency, async (file) => {
const bytes = buildSeedPdfBytes(file);
assertMetadataMatches(file, bytes);
await putObject({
bucket,
key: seedFileStorageKey(tenantId, file.id),
body: bytes,
contentType: PDF_CONTENT_TYPE,
});
});
return { bucket, uploaded: files.length };
}
export async function verifyDemoProSeedFileObjects({
tenantId,
files,
concurrency = DEFAULT_STORAGE_CONCURRENCY,
bucket = resolveStorageBucket(),
client = createSeedS3Client(),
}: {
tenantId: string;
files: readonly PersistedSeedFileObject[];
concurrency?: number;
bucket?: string;
client?: SeedS3Client;
}): Promise<{ bucket: string; verified: number }> {
const prefix = seedFileStoragePrefix(tenantId);
const expectedKeys = new Set(files.map((file) => seedFileStorageKey(tenantId, file.id)));
if (expectedKeys.size !== files.length) {
throw new Error("demo-pro seed files: expected storage keys are not unique");
}
const observedKeys = new Set<string>();
let continuationToken: string | undefined;
do {
const page = (await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: continuationToken, MaxKeys: 1_000 }))) as {
Contents?: Array<{ Key?: string }>;
IsTruncated?: boolean;
NextContinuationToken?: string;
};
for (const object of page.Contents ?? []) {
if (!object.Key) throw new Error("demo-pro seed files: storage listing returned an object without a key");
if (!object.Key.startsWith(prefix)) {
throw new Error("demo-pro seed files: storage listing returned a key outside the tenant file prefix");
}
if (!expectedKeys.has(object.Key)) {
throw new Error(`demo-pro seed files: unexpected storage object ${object.Key}`);
}
if (observedKeys.has(object.Key)) {
throw new Error(`demo-pro seed files: storage listing returned duplicate object ${object.Key}`);
}
observedKeys.add(object.Key);
}
if (page.IsTruncated && !page.NextContinuationToken) {
throw new Error("demo-pro seed files: truncated storage listing has no continuation token");
}
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (continuationToken);
for (const expectedKey of expectedKeys) {
if (!observedKeys.has(expectedKey)) throw new Error(`demo-pro seed files: missing storage object ${expectedKey}`);
}
await forEachConcurrent(files, concurrency, async (file) => {
const result = (await client.send(new GetObjectCommand({ Bucket: bucket, Key: seedFileStorageKey(tenantId, file.id) }))) as {
Body?: unknown;
ContentLength?: number;
};
const bytes = await storageBodyBytes(result.Body);
assertMetadataMatches(file, bytes);
if (result.ContentLength !== undefined && result.ContentLength !== bytes.byteLength) {
throw new Error(`demo-pro seed files: storage size drifted for ${file.id}`);
}
});
return { bucket, verified: files.length };
}
export async function deleteDemoProTenantFileObjects({
tenantId,
bucket = resolveStorageBucket(),
client = createSeedS3Client(),
}: {
tenantId: string;
bucket?: string;
client?: SeedS3Client;
}): Promise<{ bucket: string; deleted: number }> {
const prefix = seedFileStoragePrefix(tenantId);
let deleted = 0;
let previousFirstKey: string | undefined;
while (true) {
const page = (await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, MaxKeys: 1_000 }))) as {
Contents?: Array<{ Key?: string }>;
IsTruncated?: boolean;
NextContinuationToken?: string;
};
const pageKeys = (page.Contents ?? []).map(({ Key }) => {
if (!Key) throw new Error("demo-pro seed files: storage listing returned an object without a key");
return Key;
});
if (pageKeys.some((key) => !key.startsWith(prefix))) {
throw new Error("demo-pro seed files: storage listing returned a key outside the tenant file prefix");
}
if (pageKeys.length > 1_000 || new Set(pageKeys).size !== pageKeys.length) {
throw new Error("demo-pro seed files: storage listing returned an invalid cleanup page");
}
if (pageKeys.length === 0) {
if (page.IsTruncated) throw new Error("demo-pro seed files: truncated storage listing returned no objects");
break;
}
if (pageKeys[0] === previousFirstKey) {
throw new Error("demo-pro seed files: storage prefix cleanup made no progress");
}
previousFirstKey = pageKeys[0];
const result = (await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: pageKeys.map((Key) => ({ Key })), Quiet: true },
})
)) as { Errors?: unknown[] };
if ((result.Errors?.length ?? 0) > 0) throw new Error("demo-pro seed files: storage prefix cleanup failed");
deleted += pageKeys.length;
}
return { bucket, deleted };
}
export async function deleteDemoProSeedFileObjects({
tenantId,
files,
concurrency = DEFAULT_STORAGE_CONCURRENCY,
bucket = resolveStorageBucket(),
deleteObject = deleteFile,
storageConfigured = hasStorageCredentials,
}: {
tenantId: string;
files: readonly Pick<PersistedSeedFileObject, "id">[];
concurrency?: number;
bucket?: string;
deleteObject?: DeleteObject;
storageConfigured?: () => boolean;
}): Promise<{ bucket: string; deleted: number }> {
if (!storageConfigured()) throw new Error("demo-pro seed files: storage provider is not configured");
await forEachConcurrent(files, concurrency, async (file) => {
await deleteObject({ bucket, key: seedFileStorageKey(tenantId, file.id) });
});
return { bucket, deleted: files.length };
}