This commit was merged in pull request #59.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
ENDUNG_JE_TYP,
|
||||
anhaengePfad,
|
||||
dateinameSaeubern,
|
||||
groesseText,
|
||||
istErlaubterMedientyp,
|
||||
maxGroesseBytes,
|
||||
maxJeLauf,
|
||||
type Medientyp,
|
||||
} from "@/lib/anhaenge";
|
||||
import { laufZumSchreibenHolen } from "@/server/services/laeufe";
|
||||
import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Anhaenge und Fotos an Schritten (#032).
|
||||
*
|
||||
* Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad
|
||||
* entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname
|
||||
* von aussen darf keinen Pfad bestimmen.
|
||||
*
|
||||
* Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die
|
||||
* Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` —
|
||||
* dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren.
|
||||
*/
|
||||
|
||||
export class AnhangFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "AnhangFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */
|
||||
function dateiPfad(anhang: {
|
||||
readonly id: string;
|
||||
readonly kundeId: string;
|
||||
readonly laufId: string;
|
||||
readonly medientyp: string;
|
||||
}): string {
|
||||
const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin";
|
||||
return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`);
|
||||
}
|
||||
|
||||
export type AnhangEingabe = {
|
||||
readonly laufId: string;
|
||||
readonly vorlagenSchrittId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly inhalt: Uint8Array;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt einen Anhang an einem Schritt ab.
|
||||
*
|
||||
* Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann
|
||||
* die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder
|
||||
* entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt
|
||||
* waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen
|
||||
* fehlschlaegt.
|
||||
*/
|
||||
export async function anhangSpeichern(
|
||||
eingabe: AnhangEingabe,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{ readonly id: string }> {
|
||||
if (!istErlaubterMedientyp(eingabe.medientyp)) {
|
||||
throw new AnhangFehler(
|
||||
`Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` +
|
||||
"Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.",
|
||||
);
|
||||
}
|
||||
if (eingabe.inhalt.byteLength === 0) {
|
||||
throw new AnhangFehler("Die Datei ist leer.");
|
||||
}
|
||||
const grenze = maxGroesseBytes();
|
||||
if (eingabe.inhalt.byteLength > grenze) {
|
||||
throw new AnhangFehler(
|
||||
`Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` +
|
||||
`${groesseText(grenze)} je Datei.`,
|
||||
);
|
||||
}
|
||||
|
||||
const vorbereitet = await prisma.$transaction(async (tx) => {
|
||||
// Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle.
|
||||
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
|
||||
|
||||
const ergebnis = await tx.schrittergebnis.findUnique({
|
||||
where: {
|
||||
laufId_vorlagenSchrittId: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: eingabe.vorlagenSchrittId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!ergebnis) {
|
||||
throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht.");
|
||||
}
|
||||
|
||||
const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } });
|
||||
const hoechstens = maxJeLauf();
|
||||
if (vorhanden >= hoechstens) {
|
||||
throw new AnhangFehler(
|
||||
`An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` +
|
||||
"nimmt Laufzettel nicht — erst eine entfernen.",
|
||||
);
|
||||
}
|
||||
|
||||
return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id };
|
||||
});
|
||||
|
||||
const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex");
|
||||
const id = `anh_${createHash("sha256")
|
||||
.update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`)
|
||||
.digest("hex")
|
||||
.slice(0, 24)}`;
|
||||
const pfad = dateiPfad({
|
||||
id,
|
||||
kundeId: vorbereitet.kundeId,
|
||||
laufId: vorbereitet.laufId,
|
||||
medientyp: eingabe.medientyp,
|
||||
});
|
||||
|
||||
await mkdir(join(pfad, ".."), { recursive: true });
|
||||
await writeFile(pfad, eingabe.inhalt, { flag: "wx" });
|
||||
|
||||
try {
|
||||
await prisma.anhang.create({
|
||||
data: {
|
||||
id,
|
||||
schrittergebnisId: vorbereitet.schrittergebnisId,
|
||||
laufId: vorbereitet.laufId,
|
||||
kundeId: vorbereitet.kundeId,
|
||||
dateiname: dateinameSaeubern(eingabe.dateiname),
|
||||
medientyp: eingabe.medientyp,
|
||||
groesse: eingabe.inhalt.byteLength,
|
||||
pruefsumme,
|
||||
erstelltVonId: benutzer.id,
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr.
|
||||
await rm(pfad, { force: true });
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze.
|
||||
*
|
||||
* **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die
|
||||
* ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht.
|
||||
*/
|
||||
export async function anhangLesen(
|
||||
anhangId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{
|
||||
readonly inhalt: Buffer;
|
||||
readonly medientyp: string;
|
||||
readonly dateiname: string;
|
||||
readonly groesse: number;
|
||||
}> {
|
||||
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
|
||||
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
|
||||
await kundeZugriffPruefen(anhang.kundeId, benutzer);
|
||||
|
||||
const pfad = dateiPfad(anhang);
|
||||
let inhalt: Buffer;
|
||||
try {
|
||||
inhalt = await readFile(pfad);
|
||||
} catch {
|
||||
/*
|
||||
* Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung,
|
||||
* die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das
|
||||
* auch, statt „nicht gefunden“ zu behaupten.
|
||||
*/
|
||||
throw new AnhangFehler(
|
||||
`Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` +
|
||||
"eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
inhalt,
|
||||
medientyp: anhang.medientyp,
|
||||
dateiname: anhang.dateiname,
|
||||
groesse: anhang.groesse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Anhang.
|
||||
*
|
||||
* Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar
|
||||
* und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen,
|
||||
* zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist.
|
||||
*/
|
||||
export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise<void> {
|
||||
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
|
||||
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf
|
||||
// kommt kein Anhang weg.
|
||||
await laufZumSchreibenHolen(tx, anhang.laufId, benutzer);
|
||||
await tx.anhang.delete({ where: { id: anhangId } });
|
||||
});
|
||||
|
||||
await rm(dateiPfad(anhang), { force: true });
|
||||
}
|
||||
|
||||
/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */
|
||||
export async function anhaengeDesLaufs(laufId: string): Promise<
|
||||
readonly {
|
||||
readonly id: string;
|
||||
readonly schrittergebnisId: string;
|
||||
readonly dateiname: string;
|
||||
readonly medientyp: string;
|
||||
readonly groesse: number;
|
||||
readonly erstelltAm: Date;
|
||||
readonly erstelltVon: string | null;
|
||||
}[]
|
||||
> {
|
||||
const zeilen = await prisma.anhang.findMany({
|
||||
where: { laufId },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
schrittergebnisId: true,
|
||||
dateiname: true,
|
||||
medientyp: true,
|
||||
groesse: true,
|
||||
erstelltAm: true,
|
||||
erstelltVon: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
schrittergebnisId: zeile.schrittergebnisId,
|
||||
dateiname: zeile.dateiname,
|
||||
medientyp: zeile.medientyp,
|
||||
groesse: zeile.groesse,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
|
||||
export function anhangPfad(anhang: {
|
||||
readonly id: string;
|
||||
readonly kundeId: string;
|
||||
readonly laufId: string;
|
||||
readonly medientyp: string;
|
||||
}): string {
|
||||
return dateiPfad(anhang);
|
||||
}
|
||||
|
||||
export type { Medientyp };
|
||||
Reference in New Issue
Block a user