Chat: Fotos und PDFs schicken, Gespraeche wegraeumen
Filipe, mit Bildschirmfoto: "man muss die chats auch geloescht bekommen!!! am besten waere es auch wenn man da auch im chat pdfs schicken koennte. pdfs und fotos." Zwei Entscheidungen vorher abgestimmt, weil sie den Bau bestimmen: Geloescht wird NUR BEI MIR. Schicken duerfen ALLE im Gespraech, auch Creator -- anders als in der Dateiablage, wo etwas in einem Bereich landet, den mehrere sehen. Hier bekommt es genau der, mit dem man ohnehin gerade spricht. --- ANHAENGE --- Drei Wege zur selben Sache, weil Leute unterschiedlich arbeiten: die Bueroklammer, Hineinziehen und Einfuegen mit Strg+V (der Weg fuer einen Screenshot -- der liegt in der Zwischenablage und nirgends als Datei). Alle drei laufen durch dieselbe Funktion; drei Fassungen waeren drei Gelegenheiten, dass eine die Pruefung vergisst. Ein Foto wird GEZEIGT, ein PDF wird als Karte ANGEBOTEN. Das ist kein Schoenheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde, ein PDF muss man ohnehin oeffnen -- eine Vorschau davon waere ein grauer Kasten, der so tut, als koennte man etwas lesen. DIE SICHERHEIT STEHT IN dateiErkennen(). Dateiname und Content-Type kommen vom Absender und sind frei erfunden; der Typ wird deshalb aus den ersten Bytes bestimmt (PNG/GIF/WebP/JPEG/PDF, mit Breite und Hoehe im selben Durchgang). SVG steht absichtlich NICHT in der Liste -- das ist XML, das Skripte enthalten darf, ein als Bild getarntes Programm. Ausgeliefert wird spaeter genau der ERKANNTE Typ, nie der eingeschickte, dazu nosniff und "default-src 'none'; sandbox". Auf der Platte bekommt jede Datei einen erzeugten Zufallsnamen, in einem Ordner ausserhalb des Repos. Zuruecknehmen loescht die Datei wirklich -- sonst waere sie im Verlauf weg und ueber die Adresse noch da, also nur der Anschein einer Ruecknahme. --- WEGRAEUMEN --- Zwei Spalten in chat_teilnehmer statt einer, wegen eines Randfalls: Bei einem Gespraech ohne jede Nachricht waere die Grenze 0 -- und 0 heisst sonst "nie geloescht". geloescht_am unterscheidet die beiden. Die Grenze wirkt in der Liste, im Verlauf, in der Vorschau, in der SUCHE, bei den Anhaengen und in der Ungelesen-Zahl. Die Suche ist die Stelle, an der so etwas typischerweise durchrutscht: aus der Liste weg, ueber die Suche noch da. --- DER FUND: 323 PIXEL --- Ein Bild ist spaeter fertig als der Rest der Seite. Beim Oeffnen springt der Verlauf ans Ende, dann kommt das Foto an und schiebt alles darunter weg -- man steht ploetzlich mittendrin, ohne etwas angeklickt zu haben. Das width/height-Attribut am <img>, wie man es ueberall liest, hilft hier NICHT: Es gibt nur das Seitenverhaeltnis. Damit daraus eine Hoehe wird, muss die Breite feststehen -- bei "width: auto" und einem nicht geladenen Bild ist sie 0. Der Platz wird jetzt am KASTEN reserviert (aspect-ratio + max-width aus den gemessenen Massen). Und die Pruefung dazu hatte denselben Fehler wie das Auge: Sie mass in einem Fenster, in dem das Bild laengst im Zwischenspeicher lag, und meldete tadellose 0 px. Sie misst jetzt in einem FRISCHEN Browser -- der Fall, um den es geht, ist der erste. --- SICHERUNG --- chat-anhaenge/ ist im selben Zug in tools/sicherung-holen.sh eingetragen, nicht spaeter, wenn es auffaellt: Genau so ist die Luecke bei den Profilbildern entstanden. tools/wiederherstellung-proben.mjs bestaetigt: "der Server benutzt 4 Datenordner", keiner fehlt. --- Pruefung --- server/pruef-chat-anhaenge.mjs, neu, 60 Pruefungen, alle gruen. Abschnitt 3 versucht ausdruecklich, hereinzukommen: HTML als bild.png, SVG als foto.png, SVG als svg, eine Textdatei, ein PNG-Kopf ohne Inhalt -- alle fuenf mit 415 abgewiesen, ein echtes JPEG danach mit 201 angenommen (sonst bewiese der Block nur, dass gar nichts durchkommt). 13 MB geben 413 mit lesbarem Text, nicht 500. Konsolenfehler werden nach ZEITPUNKT getrennt, nicht nach Statusnummer: Was waehrend eines absichtlichen Versuchs entsteht, ist gewollt. Nach Nummer zu filtern waere bequem und wuerde ab morgen echte 404 mit verschlucken. Ausserdem gruen: pruef-chat, pruef-chat-optik, pruef-chat-ausbau, pruef-css-klassen. Angesehen bei 1440 px und bei 390 px. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
/* CHAT: ANHÄNGE UND WEGRÄUMEN (09.09.2026)
|
||||
|
||||
Filipe: "man muss die chats auch gelöscht bekommen!!!
|
||||
am besten wäre es auch wenn man da auch im chat pdfs schicken
|
||||
könnte. pdfs und fotos."
|
||||
|
||||
Zwei Dinge, eine Prüfung, weil sie sich an einer Stelle berühren:
|
||||
Wer ein Gespräch wegräumt, darf auch an dessen Anhänge nicht mehr
|
||||
herankommen. Getrennt geprüft würde genau diese Naht niemandem
|
||||
auffallen.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DIE WICHTIGSTEN PRÜFUNGEN STEHEN IN ABSCHNITT 3
|
||||
|
||||
Ein Upload ist die gefährlichste Stelle einer Website. Der Dateiname
|
||||
kommt vom Absender, der Content-Type kommt vom Absender -- wer sich
|
||||
darauf verlässt, liefert irgendwann eine HTML-Datei aus, die im
|
||||
Browser des Empfängers als Seite DIESER Domain läuft, mit Zugriff
|
||||
auf seine Sitzung. Deshalb wird hier ausdrücklich versucht, genau
|
||||
das zu tun: HTML als bild.png, SVG als foto.png, eine Datei ohne
|
||||
erkennbaren Inhalt. Alle drei MÜSSEN abgewiesen werden.
|
||||
|
||||
Und danach: Was ausgeliefert wird, muss den Typ tragen, der beim
|
||||
Hochladen ERKANNT wurde -- nicht den, der mitgeschickt wurde.
|
||||
|
||||
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
|
||||
|
||||
import { mkdtempSync, rmSync, appendFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-chatanhang-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4323, "die Chat-Anhangpruefung");
|
||||
|
||||
process.env.PORT = "4323";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
notbremse(600_000, "pruef-chat-anhaenge");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const BASIS = "http://127.0.0.1:4323";
|
||||
const ANHANG_ORDNER = join(ordner, "chat-anhaenge");
|
||||
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
|
||||
let fehler = 0, geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetztT = new Date().toISOString();
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetztT);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
||||
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetztT);
|
||||
d.close();
|
||||
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
|
||||
async function alsRolle(rolle, code, breite = 1280) {
|
||||
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
|
||||
const seite = await ctx.newPage();
|
||||
const konsole = [];
|
||||
const rauschen = [];
|
||||
/* WARUM ZWEI TÖPFE.
|
||||
|
||||
Diese Prüfung provoziert absichtlich Absagen: fünf abgewiesene
|
||||
Uploads, ein 413, drei 404 auf gesperrte Anhänge. Jede davon
|
||||
schreibt der Browser als "Failed to load resource" in die Konsole
|
||||
-- und jede davon ist der BEWEIS, dass die Abwehr gegriffen hat.
|
||||
|
||||
Erster Anlauf war, sie nach Statusnummer herauszufiltern (415, 413,
|
||||
404). Das ist bequem und falsch: Ein fehlendes Bild in der
|
||||
Oberfläche wäre ab da dauerhaft unsichtbar, weil es dieselbe Nummer
|
||||
trägt. Gefiltert wird deshalb nach ZEITPUNKT -- was während eines
|
||||
absichtlichen Versuchs entsteht, kommt in den zweiten Topf. Alles
|
||||
ausserhalb bleibt ein Befund, egal welche Nummer es hat. */
|
||||
const zustand = { stumm: false };
|
||||
seite.on("pageerror", (f) => konsole.push("Absturz auf " + seite.url() + ": " + f.message
|
||||
+ " || " + String(f.stack || "").split(/\r?\n/).slice(1, 3).join(" / ")));
|
||||
seite.on("console", (m) => {
|
||||
if (m.type() !== "error") return;
|
||||
(zustand.stumm ? rauschen : konsole).push("Konsole: " + m.text());
|
||||
});
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(900);
|
||||
return { ctx, seite, konsole, rauschen, zustand };
|
||||
}
|
||||
|
||||
/** Alles, was hier drin an Konsolenfehlern entsteht, ist gewollt.
|
||||
* Der kurze Nachlauf ist nötig, weil Konsolenmeldungen nicht im selben
|
||||
* Atemzug ankommen wie die Antwort. */
|
||||
async function absichtlich(leute, tun) {
|
||||
for (const l of leute) l.zustand.stumm = true;
|
||||
try { return await tun(); } finally {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
for (const l of leute) l.zustand.stumm = false;
|
||||
}
|
||||
}
|
||||
|
||||
const luna = await alsRolle("creator", "CODE-CREA-0001");
|
||||
const pat = await alsRolle("scout", "CODE-SCOU-0001");
|
||||
const dogi = await alsRolle("admin", "CODE-DOGI-0001");
|
||||
|
||||
async function direkt(s, mit) {
|
||||
return s.evaluate(async (m) => {
|
||||
const a = await fetch("/workspace/api/chat/direkt", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mit: m }) });
|
||||
return (await a.json()).raum_id;
|
||||
}, mit);
|
||||
}
|
||||
const raumId = await direkt(luna.seite, idPat); /* Luna <-> Patrick */
|
||||
const raumDogi = await direkt(luna.seite, idDogi); /* Luna <-> DogFather */
|
||||
|
||||
async function schicke(s, raum, text) {
|
||||
return s.evaluate(async ([r, t]) => {
|
||||
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: t }) });
|
||||
return a.ok;
|
||||
}, [raum, text]);
|
||||
}
|
||||
async function oeffne(s, id) {
|
||||
await s.goto(`${BASIS}/workspace/chat.html?raum=${id}`, { waitUntil: "networkidle" });
|
||||
await s.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
|
||||
await s.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/* Ein ECHTES PNG mit bekannten Maßen -- gezeichnet im Browser, nicht
|
||||
von Hand zusammengebaut. Eine selbst gebastelte Bytefolge würde
|
||||
beweisen, dass meine Erkennung meine Bastelei versteht; ein echtes
|
||||
PNG beweist, dass sie PNG versteht. */
|
||||
async function hochladen(s, raum, { inhalt, typ, name, text = "" }) {
|
||||
return s.evaluate(async ([r, i, t, nm, tx]) => {
|
||||
let koerper;
|
||||
if (i.art === "png" || i.art === "jpeg") {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = i.breite; c.height = i.hoehe;
|
||||
const g = c.getContext("2d");
|
||||
g.fillStyle = "#2b6cb0"; g.fillRect(0, 0, c.width, c.height);
|
||||
g.fillStyle = "#ffd166"; g.fillRect(4, 4, Math.max(2, c.width - 8), Math.max(2, c.height - 8));
|
||||
koerper = await new Promise((res) => c.toBlob(res,
|
||||
i.art === "png" ? "image/png" : "image/jpeg", 0.9));
|
||||
} else if (i.art === "roh") {
|
||||
koerper = new Blob([i.text], { type: t });
|
||||
} else if (i.art === "gross") {
|
||||
koerper = new Blob([new Uint8Array(i.bytes)], { type: t });
|
||||
}
|
||||
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": t, "X-Dateiname": encodeURIComponent(nm), "X-Text": encodeURIComponent(tx) },
|
||||
body: koerper,
|
||||
});
|
||||
let leib = null;
|
||||
try { leib = await a.json(); } catch { /* kein JSON */ }
|
||||
return { status: a.status, leib };
|
||||
}, [raum, inhalt, typ, name, text]);
|
||||
}
|
||||
|
||||
/* Ein winziges, gültiges PDF -- fünf Zeichen Kopf reichen der
|
||||
Erkennung, der Rest macht daraus eine Datei, die ein Betrachter
|
||||
auch öffnen würde. */
|
||||
const PDF_TEXT = "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
+ "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
+ "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
|
||||
+ "trailer<</Root 1 0 R>>\n%%EOF";
|
||||
|
||||
/* =======================================================================
|
||||
1. EIN FOTO SCHICKEN
|
||||
======================================================================= */
|
||||
melde("\n=== 1. Ein Foto schicken ===");
|
||||
let bildNachrichtId = 0;
|
||||
{
|
||||
await oeffne(luna.seite, raumId);
|
||||
await oeffne(pat.seite, raumId);
|
||||
|
||||
const a = await hochladen(luna.seite, raumId, {
|
||||
inhalt: { art: "png", breite: 240, hoehe: 150 },
|
||||
typ: "image/png", name: "startcheck.png", text: "Schau mal, so sieht es aus",
|
||||
});
|
||||
ok(a.status === 201, `angenommen (HTTP ${a.status})`);
|
||||
const n = a.leib?.nachricht;
|
||||
bildNachrichtId = n?.id || 0;
|
||||
ok(n?.anhang?.art === "bild" && n?.anhang?.typ === "image/png",
|
||||
`erkannt als ${n?.anhang?.art} / ${n?.anhang?.typ}`);
|
||||
ok(n?.anhang?.breite === 240 && n?.anhang?.hoehe === 150,
|
||||
`die Maße wurden aus der Datei gelesen (${n?.anhang?.breite}×${n?.anhang?.hoehe})`);
|
||||
ok(n?.anhang?.name === "startcheck.png", `der Name bleibt erhalten ("${n?.anhang?.name}")`);
|
||||
ok(n?.text === "Schau mal, so sieht es aus", "der Begleitsatz kommt mit");
|
||||
|
||||
/* UND ER KOMMT BEIM ANDEREN AN, ohne dass der etwas tut. */
|
||||
const beiPat = await pat.seite.waitForFunction(
|
||||
() => document.querySelector(".chat-nachricht:last-of-type .chat-bild img"),
|
||||
{ timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(beiPat, beiPat ? "Patrick sieht das Foto von selbst — kein Neuladen"
|
||||
: "das Foto kam bei Patrick NICHT an");
|
||||
|
||||
/* Es wird auch wirklich GEZEIGT, nicht nur eingehaengt.
|
||||
|
||||
ERST WARTEN, DANN MESSEN. Der erste Anlauf mass sofort, nachdem das
|
||||
<img> im Baum stand -- und las 0x0. Das war kein Befund am Chat,
|
||||
sondern die Frage zu frueh gestellt: Zwischen "das Element ist da"
|
||||
und "das Bild ist geladen" liegt eine Anfrage ans Netz. Eine
|
||||
Pruefung, die diesen Unterschied nicht kennt, meldet Fehler, die es
|
||||
nicht gibt -- und das ist teurer als gar nicht zu pruefen. */
|
||||
const geladen = await pat.seite.waitForFunction(() => {
|
||||
const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img");
|
||||
return !!(i && i.complete && i.naturalWidth > 0);
|
||||
}, { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
ok(geladen, geladen ? "und es laedt auch wirklich" : "das Bild kam nicht durch die Leitung");
|
||||
|
||||
const bild = await pat.seite.evaluate(() => {
|
||||
const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img");
|
||||
if (!i) return null;
|
||||
const r = i.getBoundingClientRect();
|
||||
return { geladen: i.complete && i.naturalWidth > 0, nw: i.naturalWidth, nh: i.naturalHeight,
|
||||
w: Math.round(r.width), h: Math.round(r.height),
|
||||
attrW: i.getAttribute("width"), attrH: i.getAttribute("height") };
|
||||
});
|
||||
ok(bild?.geladen && bild.nw === 240 && bild.nh === 150,
|
||||
`in Originalgroesse angekommen (${bild?.nw}×${bild?.nh} Punkte)`);
|
||||
ok(bild?.w > 40 && bild?.h > 20, `und ist sichtbar groß (${bild?.w}×${bild?.h} px)`);
|
||||
/* Die Maße stehen am Bild, damit der Verlauf beim Laden nicht
|
||||
springt -- das ist der Zweck, nicht Zierrat. */
|
||||
ok(bild?.attrW === "240" && bild?.attrH === "150",
|
||||
`Breite und Höhe stehen am Bild (${bild?.attrW}×${bild?.attrH}) — der Verlauf springt nicht`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1b. DER VERLAUF STEHT TROTZDEM UNTEN
|
||||
|
||||
Ein Bild wird SPÄTER fertig als der Rest der Seite. Wer beim Öffnen
|
||||
ans Ende springt und danach ein Foto nachlädt, steht plötzlich
|
||||
mittendrin -- und die letzten Nachrichten stehen unter dem
|
||||
Bildschirmrand, ohne dass jemand etwas angeklickt hätte.
|
||||
|
||||
Genau das war auf dem ersten Bildschirmfoto zu sehen. Deshalb steht
|
||||
die Messung jetzt hier und nicht im Auge.
|
||||
======================================================================= */
|
||||
melde("\n=== 1b. Der Verlauf steht unten ===");
|
||||
{
|
||||
/* Ein GROSSES Bild -- eines, das an den Deckel von 340 px stösst.
|
||||
Ein kleines würde die Frage gar nicht stellen. */
|
||||
await hochladen(luna.seite, raumId, {
|
||||
inhalt: { art: "png", breite: 900, hoehe: 600 }, typ: "image/png", name: "gross.png",
|
||||
});
|
||||
for (let i = 1; i <= 4; i++) await schicke(pat.seite, raumId, `Danach kommt noch Zeile ${i}.`);
|
||||
|
||||
/* IN EINEM FRISCHEN BROWSER, und das ist der Kern dieser Prüfung.
|
||||
|
||||
Der erste Anlauf mass in Lunas offenem Fenster -- dort lag das Bild
|
||||
längst im Zwischenspeicher, war also sofort da, und die Messung
|
||||
ergab tadellose 0 px. Der Fehler zeigte sich erst beim ERSTEN
|
||||
Laden. Eine Prüfung, die nur den warmen Fall kennt, prüft genau den
|
||||
Fall nicht, um den es geht. */
|
||||
const frisch = await alsRolle("creator", "CODE-CREA-0001");
|
||||
await frisch.seite.goto(`${BASIS}/workspace/chat.html?raum=${raumId}`, { waitUntil: "networkidle" });
|
||||
await frisch.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
|
||||
await frisch.seite.waitForFunction(() => {
|
||||
const b = [...document.querySelectorAll(".chat-bild img")];
|
||||
return b.length > 0 && b.every((i) => i.complete && i.naturalWidth > 0);
|
||||
}, { timeout: 10000 }).then(() => true).catch(() => false);
|
||||
await frisch.seite.waitForTimeout(700);
|
||||
|
||||
const lage = await frisch.seite.evaluate(() => {
|
||||
const v = document.getElementById("verlauf");
|
||||
const gross = [...document.querySelectorAll(".chat-bild")]
|
||||
.map((k) => Math.round(k.getBoundingClientRect().height));
|
||||
return { rest: Math.round(v.scrollHeight - v.scrollTop - v.clientHeight),
|
||||
knopf: !document.getElementById("zum-neuesten").hidden, gross };
|
||||
});
|
||||
ok(lage.rest < 40,
|
||||
`beim ERSTEN Laden steht der Verlauf am Ende (${lage.rest} px Rest, Bildhöhen ${lage.gross.join("/")})`);
|
||||
ok(!lage.knopf, "und der Knopf „Zum neuesten“ erscheint nicht ohne Anlass");
|
||||
ok(lage.gross.every((h) => h <= 342),
|
||||
`kein Bild sprengt den Deckel von 340 px (${lage.gross.join("/")})`);
|
||||
await frisch.ctx.close();
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. EIN PDF SCHICKEN
|
||||
======================================================================= */
|
||||
melde("\n=== 2. Ein PDF schicken ===");
|
||||
let pdfNachrichtId = 0;
|
||||
{
|
||||
const a = await hochladen(luna.seite, raumId, {
|
||||
inhalt: { art: "roh", text: PDF_TEXT },
|
||||
typ: "application/pdf", name: "Vertrag Größe.pdf",
|
||||
});
|
||||
ok(a.status === 201, `angenommen (HTTP ${a.status})`);
|
||||
pdfNachrichtId = a.leib?.nachricht?.id || 0;
|
||||
ok(a.leib?.nachricht?.anhang?.art === "pdf", `erkannt als ${a.leib?.nachricht?.anhang?.art}`);
|
||||
ok(a.leib?.nachricht?.anhang?.name === "Vertrag Größe.pdf",
|
||||
`Umlaute überleben die Kopfzeile ("${a.leib?.nachricht?.anhang?.name}")`);
|
||||
|
||||
await oeffne(luna.seite, raumId);
|
||||
const karte = await luna.seite.evaluate(() => {
|
||||
const k = document.querySelector(".chat-nachricht:last-of-type .chat-datei");
|
||||
if (!k) return null;
|
||||
return { name: k.querySelector(".chat-datei__name")?.textContent,
|
||||
mass: k.querySelector(".chat-datei__mass")?.textContent,
|
||||
ziel: k.target, rel: k.rel, breit: Math.round(k.getBoundingClientRect().width) };
|
||||
});
|
||||
ok(karte?.name === "Vertrag Größe.pdf", `die Karte zeigt den Namen ("${karte?.name}")`);
|
||||
ok(/\d/.test(karte?.mass || ""), `und die Größe ("${karte?.mass}")`);
|
||||
ok(karte?.ziel === "_blank" && /noopener/.test(karte?.rel || ""),
|
||||
"sie öffnet in einem neuen Tab, mit noopener");
|
||||
/* KEIN Bild-Kasten für ein PDF -- eine graue Fläche, die so tut, als
|
||||
könnte man etwas lesen, wäre schlechter als eine ehrliche Karte. */
|
||||
const kaeme = await luna.seite.$$eval(".chat-nachricht:last-of-type .chat-bild", (b) => b.length);
|
||||
ok(kaeme === 0, `für ein PDF wird kein Bildkasten gebaut (${kaeme})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. WAS NICHT HEREINDARF — die eigentliche Prüfung
|
||||
|
||||
Alles hier MUSS scheitern. Ein "ok" in diesem Abschnitt heisst: der
|
||||
Angriff wurde abgewiesen.
|
||||
======================================================================= */
|
||||
melde("\n=== 3. Was nicht hereindarf ===");
|
||||
{
|
||||
const versuche = [
|
||||
["HTML, das sich png nennt",
|
||||
{ art: "roh", text: '<html><body><script>alert(document.cookie)</script></body></html>' },
|
||||
"image/png", "harmlos.png"],
|
||||
["SVG, das sich png nennt",
|
||||
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>' },
|
||||
"image/png", "logo.png"],
|
||||
["SVG als svg",
|
||||
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' },
|
||||
"image/svg+xml", "logo.svg"],
|
||||
["eine Textdatei",
|
||||
{ art: "roh", text: "nur Text, kein Bild, kein PDF" }, "text/plain", "notiz.txt"],
|
||||
["ein PNG-Kopf ohne Inhalt",
|
||||
{ art: "roh", text: "\x89PNG\r\n\x1a\n" + "x".repeat(40) }, "image/png", "fast.png"],
|
||||
];
|
||||
await absichtlich([luna], async () => {
|
||||
for (const [was, inhalt, typ, name] of versuche) {
|
||||
const a = await hochladen(luna.seite, raumId, { inhalt, typ, name });
|
||||
ok(a.status === 415,
|
||||
`${was}: abgewiesen (HTTP ${a.status}${a.status === 415 ? "" : " — KAM DURCH"})`);
|
||||
}
|
||||
});
|
||||
|
||||
/* GEGENPROBE ZUR GEGENPROBE: Ein echtes JPEG geht durch. Ohne das
|
||||
bewiese der Block oben nur, dass gar nichts angenommen wird. */
|
||||
const echt = await hochladen(luna.seite, raumId, {
|
||||
inhalt: { art: "jpeg", breite: 120, hoehe: 90 }, typ: "image/jpeg", name: "foto.jpg",
|
||||
});
|
||||
ok(echt.status === 201 && echt.leib?.nachricht?.anhang?.typ === "image/jpeg",
|
||||
`ein echtes JPEG geht durch (HTTP ${echt.status}, ${echt.leib?.nachricht?.anhang?.typ})`);
|
||||
ok(echt.leib?.nachricht?.anhang?.breite === 120 && echt.leib?.nachricht?.anhang?.hoehe === 90,
|
||||
`auch beim JPEG stimmen die Maße (${echt.leib?.nachricht?.anhang?.breite}×${echt.leib?.nachricht?.anhang?.hoehe})`);
|
||||
|
||||
/* Zu gross: Das muss eine LESBARE Absage sein, kein 500er. */
|
||||
const zuGross = await absichtlich([luna], () => hochladen(luna.seite, raumId, {
|
||||
inhalt: { art: "gross", bytes: 13 * 1024 * 1024 }, typ: "image/png", name: "riesig.png",
|
||||
}));
|
||||
ok(zuGross.status === 413, `zu groß wird mit 413 abgelehnt, nicht mit 500 (${zuGross.status})`);
|
||||
ok(/MB/.test(zuGross.leib?.fehler || ""),
|
||||
`und die Meldung sagt, was gilt: "${zuGross.leib?.fehler}"`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. WIE DER ANHANG AUSGELIEFERT WIRD
|
||||
======================================================================= */
|
||||
melde("\n=== 4. Ausliefern ===");
|
||||
{
|
||||
const kopf = await luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return { status: a.status,
|
||||
typ: a.headers.get("content-type"),
|
||||
lage: a.headers.get("content-disposition"),
|
||||
sniff: a.headers.get("x-content-type-options"),
|
||||
csp: a.headers.get("content-security-policy") };
|
||||
}, bildNachrichtId);
|
||||
ok(kopf.status === 200, `das Foto kommt (HTTP ${kopf.status})`);
|
||||
ok(kopf.typ === "image/png", `mit dem ERKANNTEN Typ (${kopf.typ})`);
|
||||
ok(/^inline/.test(kopf.lage || ""), `zum Ansehen, nicht als Download (${kopf.lage})`);
|
||||
ok(kopf.sniff === "nosniff", "der Browser darf sich keinen anderen Typ ausdenken");
|
||||
ok(/sandbox/.test(kopf.csp || ""), `und die Richtlinie nimmt jede Ausführung (${kopf.csp})`);
|
||||
|
||||
const alsDownload = await luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}?laden=1`, { cache: "no-store" });
|
||||
return a.headers.get("content-disposition");
|
||||
}, bildNachrichtId);
|
||||
ok(/^attachment/.test(alsDownload || ""), `mit ?laden=1 wird es ein Download (${alsDownload})`);
|
||||
|
||||
/* WER NICHT IM RAUM IST, BEKOMMT NICHTS. DogFather sieht sonst
|
||||
überall alles -- hier ausdrücklich nicht: Ein Gespräch zwischen
|
||||
zwei anderen ist kein Bereich, den man verwaltet. */
|
||||
const fremd = await absichtlich([dogi], () => dogi.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return a.status;
|
||||
}, bildNachrichtId));
|
||||
ok(fremd === 404, `wer nicht im Gespräch ist, bekommt 404 (${fremd})`);
|
||||
|
||||
/* Und die Datei liegt AUSSERHALB des Web-Verzeichnisses. */
|
||||
ok(existsSync(ANHANG_ORDNER) && readdirSync(ANHANG_ORDNER).length >= 3,
|
||||
`die Dateien liegen im Datenordner (${existsSync(ANHANG_ORDNER) ? readdirSync(ANHANG_ORDNER).length : 0} Stück)`);
|
||||
const namen = readdirSync(ANHANG_ORDNER);
|
||||
ok(!namen.includes("startcheck.png") && namen.every((x) => /^[a-z0-9]+-[a-f0-9]{16}\.[a-z]+$/.test(x)),
|
||||
`auf der Platte steht ein erzeugter Name, nie der eingeschickte (${namen[0]})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. ZURÜCKNEHMEN NIMMT AUCH DIE DATEI
|
||||
======================================================================= */
|
||||
melde("\n=== 5. Zurücknehmen ===");
|
||||
{
|
||||
const vorher = readdirSync(ANHANG_ORDNER).length;
|
||||
const weg = await luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
|
||||
return a.status;
|
||||
}, pdfNachrichtId);
|
||||
ok(weg === 200, `die Nachricht wird zurückgenommen (${weg})`);
|
||||
|
||||
const nachher = readdirSync(ANHANG_ORDNER).length;
|
||||
ok(nachher === vorher - 1, `die Datei ist von der Platte weg (${vorher} → ${nachher})`);
|
||||
|
||||
const nochDa = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return a.status;
|
||||
}, pdfNachrichtId));
|
||||
ok(nochDa === 404,
|
||||
`und über die Adresse kommt niemand mehr heran (${nochDa}) — sonst wäre es nur der Anschein einer Rücknahme`);
|
||||
|
||||
/* GEGENPROBE: Das Foto von vorhin ist NICHT mitgelöscht worden. */
|
||||
const anderes = await luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return a.status;
|
||||
}, bildNachrichtId);
|
||||
ok(anderes === 200, `das andere Foto liegt unberührt da (${anderes})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR
|
||||
======================================================================= */
|
||||
melde("\n=== 6. Wegräumen ===");
|
||||
{
|
||||
await schicke(pat.seite, raumId, "Das ist ein Satz, den es nur hier gibt: Zaunkönig.");
|
||||
await oeffne(luna.seite, raumId);
|
||||
await oeffne(pat.seite, raumId);
|
||||
|
||||
const lunaVorher = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
|
||||
const patVorher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
|
||||
ok(lunaVorher > 3 && patVorher > 3, `beide sehen den Verlauf (${lunaVorher} / ${patVorher})`);
|
||||
|
||||
luna.seite.once("dialog", (dlg) => dlg.accept());
|
||||
await luna.seite.click("#gespraech-weg");
|
||||
await luna.seite.waitForTimeout(900);
|
||||
|
||||
const inListe = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
|
||||
ok(!inListe.some((t) => /Patrick/.test(t)),
|
||||
`es ist aus Lunas Liste verschwunden (${inListe.length} übrig: ${inListe.map((x) => x.trim().split("\n")[0]).join(", ")})`);
|
||||
|
||||
/* DAS ENTSCHEIDENDE: Bei Patrick ist alles noch da. */
|
||||
await oeffne(pat.seite, raumId);
|
||||
const patNachher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
|
||||
ok(patNachher === patVorher,
|
||||
`Patrick hat weiterhin alle ${patNachher} Nachrichten — niemand kann einem anderen den Verlauf nehmen`);
|
||||
|
||||
/* Auch beim direkten Aufruf sieht Luna nichts mehr davon. */
|
||||
const beiLuna = await luna.seite.evaluate(async (r) => {
|
||||
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
|
||||
return (await a.json()).nachrichten.length;
|
||||
}, raumId);
|
||||
ok(beiLuna === 0, `auch direkt abgefragt ist der Verlauf für Luna leer (${beiLuna})`);
|
||||
|
||||
/* UND DIE SUCHE. Das ist die Stelle, an der so etwas typischerweise
|
||||
durchrutscht: aus der Liste weg, über die Suche noch da. */
|
||||
const findetLuna = await luna.seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
|
||||
return (await a.json()).treffer.length;
|
||||
});
|
||||
const findetPat = await pat.seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
|
||||
return (await a.json()).treffer.length;
|
||||
});
|
||||
ok(findetLuna === 0, `die Suche findet es bei Luna nicht mehr (${findetLuna})`);
|
||||
ok(findetPat === 1, `bei Patrick schon (${findetPat}) — sonst wäre es doch für beide gelöscht`);
|
||||
|
||||
/* Und an den Anhang kommt Luna auch nicht mehr. */
|
||||
const anhangLuna = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return a.status;
|
||||
}, bildNachrichtId));
|
||||
const anhangPat = await pat.seite.evaluate(async (id) => {
|
||||
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
|
||||
return a.status;
|
||||
}, bildNachrichtId);
|
||||
ok(anhangLuna === 404 && anhangPat === 200,
|
||||
`der Anhang ist für Luna zu (${anhangLuna}) und für Patrick offen (${anhangPat})`);
|
||||
|
||||
/* Die Zahl in der Kopfleiste darf davon nicht steigen. */
|
||||
const offen1 = await luna.seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/chat/ungelesen");
|
||||
return (await a.json()).ungelesen;
|
||||
});
|
||||
ok(offen1 === 0, `nichts zählt mehr als ungelesen (${offen1})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
7. UND ES KOMMT ZURÜCK, WENN JEMAND SCHREIBT
|
||||
======================================================================= */
|
||||
melde("\n=== 7. Es kommt zurück ===");
|
||||
{
|
||||
await schicke(pat.seite, raumId, "Hallo? Bist du noch da?");
|
||||
await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
|
||||
await luna.seite.waitForTimeout(900);
|
||||
|
||||
const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
|
||||
ok(liste.some((t) => /Patrick/.test(t)), "das Gespräch ist wieder in der Liste");
|
||||
|
||||
await oeffne(luna.seite, raumId);
|
||||
const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
|
||||
ok(texte.length === 1 && /Bist du noch da/.test(texte[0]),
|
||||
`aber NUR mit dem Neuen (${texte.length} Nachricht: "${texte[0]}")`);
|
||||
ok(!texte.some((t) => /Zaunkönig/.test(t)), "das Alte bleibt weg");
|
||||
|
||||
/* Auch die Suche bringt das Alte nicht zurück. */
|
||||
const wieder = await luna.seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
|
||||
return (await a.json()).treffer.length;
|
||||
});
|
||||
ok(wieder === 0, `und die Suche auch nicht (${wieder})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
8. EIN GESPRÄCH OHNE JEDE NACHRICHT
|
||||
|
||||
Der Randfall, für den es die zweite Spalte gibt: Hier ist die
|
||||
höchste Nachrichtennummer 0 -- und 0 heisst sonst "nie gelöscht".
|
||||
======================================================================= */
|
||||
melde("\n=== 8. Das leere Gespräch ===");
|
||||
{
|
||||
await oeffne(luna.seite, raumDogi);
|
||||
const leer = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
|
||||
ok(leer === 0, `das Gespräch mit DogFather ist leer (${leer})`);
|
||||
|
||||
luna.seite.once("dialog", (dlg) => dlg.accept());
|
||||
await luna.seite.click("#gespraech-weg");
|
||||
await luna.seite.waitForTimeout(900);
|
||||
|
||||
const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
|
||||
ok(!liste.some((t) => /Filipe/.test(t)),
|
||||
`auch ein leeres Gespräch lässt sich wegräumen (${liste.length} übrig)`);
|
||||
|
||||
/* GEGENPROBE: Schreibt DogFather, ist es wieder da. */
|
||||
await schicke(dogi.seite, raumDogi, "Kurze Frage noch.");
|
||||
await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
|
||||
await luna.seite.waitForTimeout(900);
|
||||
const liste2 = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
|
||||
ok(liste2.some((t) => /Filipe/.test(t)), "und es kommt zurück, sobald er schreibt");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
9. KEINE STILLEN FEHLER
|
||||
======================================================================= */
|
||||
melde("\n=== 9. Stille Fehler ===");
|
||||
{
|
||||
/* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT.
|
||||
|
||||
Abschnitt 3 lädt fünfmal Unfug hoch und einmal etwas zu Grosses.
|
||||
Jede dieser Absagen schreibt der Browser als "Failed to load
|
||||
resource … 415" in die Konsole -- das ist der BEWEIS, dass die
|
||||
Abwehr gegriffen hat, nicht ein Mangel.
|
||||
|
||||
Der Filter ist eng gefasst: genau diese Zeile, genau diese beiden
|
||||
Nummern. Ein weiter gefasster ("alles mit 'Failed'") wäre bequem
|
||||
und würde ab morgen echte Ausfälle mit verschlucken. */
|
||||
const echte = [...luna.konsole, ...pat.konsole, ...dogi.konsole];
|
||||
const gewollt = [...luna.rauschen, ...pat.rauschen, ...dogi.rauschen];
|
||||
ok(echte.length === 0, echte.length ? "Fehler in der Konsole: " + echte.slice(0, 4).join(" | ")
|
||||
: `keine unerwarteten Fehler in drei Browsern (${gewollt.length} provozierte Absagen sauber getrennt)`);
|
||||
|
||||
/* GEGENPROBE ZUR TRENNUNG: Der zweite Topf darf nicht leer sein.
|
||||
Wäre er es, hätte die Trennung nichts getan -- und der Haken
|
||||
darüber wäre grün, ohne je etwas auseinandergehalten zu haben.
|
||||
Neun absichtliche Absagen: fünf Uploads, ein 413, drei 404. */
|
||||
ok(gewollt.length >= 6,
|
||||
`die absichtlichen Absagen landen wirklich im zweiten Topf (${gewollt.length})`);
|
||||
await luna.seite.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
|
||||
await luna.seite.waitForTimeout(500);
|
||||
ok(luna.konsole.some((z) => /Absicht/.test(z)),
|
||||
`ein absichtlicher Fehler wird bemerkt (${luna.konsole.length})`);
|
||||
}
|
||||
|
||||
await luna.ctx.close();
|
||||
await pat.ctx.close();
|
||||
await dogi.ctx.close();
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
melde(`\n${geprueft} Prüfungen`);
|
||||
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
+437
-16
@@ -38,15 +38,32 @@
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join, extname, basename } from "node:path";
|
||||
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const chatRouter = express.Router();
|
||||
|
||||
const TEXT_MAX = 4000;
|
||||
const NAME_MAX = 80;
|
||||
|
||||
/* ---------- Anhänge ------------------------------------------------------
|
||||
Derselbe Weg wie in der Dateiablage, und aus denselben Gründen:
|
||||
Der Browser schickt die Datei roh im Rumpf, der Name steht im Kopf.
|
||||
express.raw() nimmt das entgegen -- multipart/form-data von Hand zu
|
||||
zerlegen wäre an dieser Stelle die gefährlichere Übung.
|
||||
|
||||
12 MB und nicht 25 wie in der Ablage: Ein Chat sammelt viele kleine
|
||||
Dateien, kein Archiv grosser. Ein Handyfoto liegt bei 2 bis 5 MB,
|
||||
ein PDF mit Bildern selten über 10. */
|
||||
const ANHANG_MAX = 12 * 1024 * 1024;
|
||||
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
|
||||
const ANHANG_NAME_MAX = 200;
|
||||
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
/* ---------- Schranke ---------------------------------------------------- */
|
||||
@@ -141,6 +158,129 @@ function direktRaum(personA, idB, { anlegen = true } = {}) {
|
||||
return raumId;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
WAS IST DAS WIRKLICH? — die Erkennung an den ersten Bytes
|
||||
|
||||
DIE GANZE SICHERHEIT DER ANHÄNGE HÄNGT AN DIESER FUNKTION.
|
||||
|
||||
Der Dateiname kommt vom Absender. Der Content-Type kommt vom
|
||||
Absender. Beide kann man frei erfinden. Wer sich auf sie verlässt,
|
||||
liefert irgendwann eine HTML- oder SVG-Datei mit der Endung .png
|
||||
aus -- und die läuft dann im Browser als Seite DIESER Domain, mit
|
||||
Zugriff auf die Sitzung des Empfängers. Das ist der Standardfehler
|
||||
bei selbst gebauten Uploads, und er ist von aussen nicht zu sehen.
|
||||
|
||||
Deshalb: Der Typ wird aus dem Inhalt bestimmt. Was hier nicht
|
||||
erkannt wird, kommt nicht herein -- und ausgeliefert wird später
|
||||
genau der Typ, der HIER festgestellt wurde, nie der eingeschickte.
|
||||
|
||||
SVG STEHT ABSICHTLICH NICHT IN DER LISTE. Eine SVG-Datei ist ein
|
||||
XML-Dokument und darf Skripte enthalten; sie ist als "Bild"
|
||||
getarnter Programmtext. Fotos sind PNG, JPEG, WebP oder GIF.
|
||||
|
||||
Breite und Höhe kommen im selben Durchgang mit: Der Verlauf
|
||||
reserviert damit den Platz, bevor das Bild da ist, und springt beim
|
||||
Laden nicht. Lassen sie sich nicht ablesen, ist das kein Grund zur
|
||||
Ablehnung -- dann steht eben 0, und der Platz wird geschätzt.
|
||||
===================================================================== */
|
||||
function dateiErkennen(b) {
|
||||
if (!Buffer.isBuffer(b) || b.length < 16) return null;
|
||||
const kopf = (von, bis) => b.toString("latin1", von, bis);
|
||||
|
||||
/* --- PNG: acht feste Bytes, danach muss IHDR kommen. -------------- */
|
||||
if (b[0] === 0x89 && kopf(1, 4) === "PNG" && b[4] === 0x0d && b[5] === 0x0a
|
||||
&& b[6] === 0x1a && b[7] === 0x0a) {
|
||||
if (b.length >= 24 && kopf(12, 16) === "IHDR") {
|
||||
return { art: "bild", typ: "image/png", endung: ".png",
|
||||
breite: b.readUInt32BE(16), hoehe: b.readUInt32BE(20) };
|
||||
}
|
||||
return null; /* PNG-Kopf ohne IHDR ist keins */
|
||||
}
|
||||
|
||||
/* --- GIF ---------------------------------------------------------- */
|
||||
if (kopf(0, 6) === "GIF87a" || kopf(0, 6) === "GIF89a") {
|
||||
return { art: "bild", typ: "image/gif", endung: ".gif",
|
||||
breite: b.readUInt16LE(6), hoehe: b.readUInt16LE(8) };
|
||||
}
|
||||
|
||||
/* --- WebP: RIFF-Behälter mit der Kennung WEBP ---------------------- */
|
||||
if (kopf(0, 4) === "RIFF" && kopf(8, 12) === "WEBP") {
|
||||
let breite = 0, hoehe = 0;
|
||||
const sorte = kopf(12, 16);
|
||||
try {
|
||||
if (sorte === "VP8 " && b.length > 30 && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
|
||||
breite = b.readUInt16LE(26) & 0x3fff;
|
||||
hoehe = b.readUInt16LE(28) & 0x3fff;
|
||||
} else if (sorte === "VP8L" && b.length > 25 && b[20] === 0x2f) {
|
||||
/* Vier Bytes, aus denen zweimal 14 Bit gelesen werden. */
|
||||
const w = b.readUInt32LE(21);
|
||||
breite = (w & 0x3fff) + 1;
|
||||
hoehe = ((w >> 14) & 0x3fff) + 1;
|
||||
} else if (sorte === "VP8X" && b.length > 30) {
|
||||
breite = (b[24] | (b[25] << 8) | (b[26] << 16)) + 1;
|
||||
hoehe = (b[27] | (b[28] << 8) | (b[29] << 16)) + 1;
|
||||
}
|
||||
} catch { breite = 0; hoehe = 0; }
|
||||
return { art: "bild", typ: "image/webp", endung: ".webp", breite, hoehe };
|
||||
}
|
||||
|
||||
/* --- JPEG: SOI, danach muss ein weiterer Marker folgen. ------------ */
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
||||
let breite = 0, hoehe = 0;
|
||||
let i = 2;
|
||||
/* Durch die Abschnitte springen, bis ein Bildkopf (SOF) kommt.
|
||||
0xc4/0xc8/0xcc sind KEINE Bildköpfe (Huffman-Tabellen u. ä.) --
|
||||
wer die mitnimmt, liest dort Unsinn als Maße. */
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) { i++; continue; }
|
||||
const m = b[i + 1];
|
||||
if (m === 0xff) { i++; continue; } /* Füllbyte */
|
||||
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; }
|
||||
if (m === 0xda) break; /* ab hier Bilddaten */
|
||||
const laenge = b.readUInt16BE(i + 2);
|
||||
if (laenge < 2) break;
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
||||
hoehe = b.readUInt16BE(i + 5);
|
||||
breite = b.readUInt16BE(i + 7);
|
||||
break;
|
||||
}
|
||||
i += 2 + laenge;
|
||||
}
|
||||
return { art: "bild", typ: "image/jpeg", endung: ".jpg", breite, hoehe };
|
||||
}
|
||||
|
||||
/* --- PDF ---------------------------------------------------------- */
|
||||
if (kopf(0, 5) === "%PDF-") {
|
||||
return { art: "pdf", typ: "application/pdf", endung: ".pdf", breite: 0, hoehe: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte
|
||||
* bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit
|
||||
* "../" darin nirgendwo hinkommt. */
|
||||
function anhangName(roh, endung) {
|
||||
let name;
|
||||
try { name = decodeURIComponent(roh || ""); } catch { name = String(roh || ""); }
|
||||
name = basename(name).replace(/[\x00-\x1f<>:"/\\|?*]/g, "_").trim();
|
||||
name = name.slice(0, ANHANG_NAME_MAX);
|
||||
if (!name) name = "anhang" + endung;
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Bis zu welcher Nachricht hat DIESE Person das Gespräch weggeräumt?
|
||||
* 0 heisst: gar nicht. Steht an einer Stelle, damit keine Abfrage sie
|
||||
* vergisst -- eine zweite Fassung wäre ein Gespräch, das in der Liste
|
||||
* weg ist und in der Suche noch auftaucht. */
|
||||
function geloeschtBis(raumId, personId) {
|
||||
try {
|
||||
return db().prepare(
|
||||
"SELECT geloescht_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
|
||||
.get(raumId, personId)?.geloescht_bis ?? 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
/** Wie heißt dieser Raum für DIESE Person?
|
||||
*
|
||||
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
|
||||
@@ -179,27 +319,44 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => {
|
||||
/** Meine Gespräche, das jüngste zuerst. */
|
||||
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
try {
|
||||
/* WEGGERÄUMTE GESPRÄCHE STEHEN NICHT IN DER LISTE.
|
||||
|
||||
Die Bedingung sitzt hier in der Abfrage und nicht in einem
|
||||
Filter danach: Was nicht herauskommt, kann auch niemand
|
||||
versehentlich wieder hineinreichen.
|
||||
|
||||
Zwei Fälle, und beide müssen stimmen:
|
||||
* Es kam etwas Neues -> das Gespräch ist wieder da, aber nur
|
||||
mit dem Neuen (id > geloescht_bis).
|
||||
* Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
|
||||
Nachricht gab; dafür steht geloescht_am. */
|
||||
const reihen = db().prepare(`
|
||||
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung
|
||||
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung, t.geloescht_bis
|
||||
FROM chat_raeume r
|
||||
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL
|
||||
AND (t.geloescht_am IS NULL
|
||||
OR EXISTS (SELECT 1 FROM chat_nachrichten n
|
||||
WHERE n.raum_id = r.id AND n.id > t.geloescht_bis))
|
||||
ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`)
|
||||
.all(req.person.id);
|
||||
|
||||
const raeume = reihen.map((r) => {
|
||||
const leute = teilnehmerVon(r.id);
|
||||
/* Die letzte Nachricht als Vorschau -- ohne sie ist eine
|
||||
Gesprächsliste nur eine Namensliste. */
|
||||
Gesprächsliste nur eine Namensliste. Sie muss dieselbe Grenze
|
||||
achten wie der Verlauf: Sonst stünde in der Vorschau ein Satz,
|
||||
den man beim Öffnen nirgends findet. */
|
||||
const letzte = db().prepare(`
|
||||
SELECT n.text, n.erstellt, n.weg_am, p.name AS von
|
||||
SELECT n.text, n.erstellt, n.weg_am, n.anhang_art, p.name AS von
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id);
|
||||
WHERE n.raum_id = ? AND n.id > ? ORDER BY n.id DESC LIMIT 1`)
|
||||
.get(r.id, r.geloescht_bis);
|
||||
const ungelesen = db().prepare(`
|
||||
SELECT COUNT(*) AS n FROM chat_nachrichten
|
||||
WHERE raum_id = ? AND id > ? AND person_id <> ?`)
|
||||
.get(r.id, r.gelesen_bis, req.person.id).n;
|
||||
WHERE raum_id = ? AND id > ? AND id > ? AND person_id <> ?`)
|
||||
.get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -208,7 +365,13 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
leitung: !!r.leitung,
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
|
||||
letzte: letzte ? {
|
||||
text: letzte.weg_am ? null : letzte.text.slice(0, 140),
|
||||
/* Ein Anhang ohne Begleittext hätte hier eine leere Zeile
|
||||
hinterlassen. "Foto"/"PDF" ist die Vorschau, die man in
|
||||
jeder Gesprächsliste erwartet. */
|
||||
text: letzte.weg_am ? null
|
||||
: (letzte.text ? letzte.text.slice(0, 140)
|
||||
: (letzte.anhang_art === "bild" ? "Foto"
|
||||
: letzte.anhang_art === "pdf" ? "PDF" : "")),
|
||||
zurueckgenommen: !!letzte.weg_am,
|
||||
von: letzte.von, erstellt: letzte.erstellt,
|
||||
} : null,
|
||||
@@ -308,17 +471,24 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
|
||||
`LEFT` und nicht `INNER`: Die meisten Nachrichten sind keine
|
||||
Antworten, und die duerfen nicht verschwinden. */
|
||||
/* WAS ICH WEGGERÄUMT HABE, KOMMT NICHT ZURÜCK. Die Grenze steht im
|
||||
WHERE und nicht in einem Filter danach -- sonst wäre sie eine
|
||||
Zeile davon entfernt, beim nächsten Umbau vergessen zu werden. */
|
||||
const weg = geloeschtBis(raumId, req.person.id);
|
||||
const reihen = db().prepare(`
|
||||
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
|
||||
p.bild AS von_bild,
|
||||
n.antwort_auf,
|
||||
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von
|
||||
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
|
||||
n.anhang_breite, n.anhang_hoehe,
|
||||
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
|
||||
a.anhang_art AS zitat_anhang
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
LEFT JOIN chat_nachrichten a ON a.id = n.antwort_auf AND a.raum_id = n.raum_id
|
||||
LEFT JOIN personen ap ON ap.id = a.person_id
|
||||
WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""}
|
||||
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId]));
|
||||
WHERE n.raum_id = ? AND n.id > ? ${vor ? "AND n.id < ?" : ""}
|
||||
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, weg, vor] : [raumId, weg]));
|
||||
|
||||
/* DER GELESEN-STAND, BEVOR ER GESETZT WIRD.
|
||||
|
||||
@@ -357,9 +527,20 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
antwort: n.antwort_auf ? {
|
||||
id: n.antwort_auf,
|
||||
von: n.zitat_von || "Gelöscht",
|
||||
text: n.zitat_weg ? null : String(n.zitat_text || "").slice(0, 140),
|
||||
text: n.zitat_weg ? null
|
||||
: (String(n.zitat_text || "").slice(0, 140)
|
||||
|| (n.zitat_anhang === "bild" ? "Foto" : n.zitat_anhang === "pdf" ? "PDF" : "")),
|
||||
zurueckgenommen: !!n.zitat_weg,
|
||||
} : null,
|
||||
/* Der Anhang wird als fertige ADRESSE geliefert, nicht als
|
||||
Dateiname. Sonst setzt jede Stelle im Browser denselben Pfad
|
||||
zusammen -- und eine davon setzt ihn irgendwann anders. */
|
||||
anhang: (!n.weg_am && n.anhang_art) ? {
|
||||
art: n.anhang_art, typ: n.anhang_typ,
|
||||
name: n.anhang_name, groesse: n.anhang_groesse,
|
||||
breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
|
||||
weg: `/workspace/api/chat/anhang/${n.id}`,
|
||||
} : null,
|
||||
})),
|
||||
});
|
||||
} catch (fehler) {
|
||||
@@ -419,6 +600,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
text: zitat.weg_am ? null : String(zitat.text || "").slice(0, 140),
|
||||
zurueckgenommen: !!zitat.weg_am,
|
||||
} : null,
|
||||
anhang: null,
|
||||
};
|
||||
|
||||
/* Sofort an alle, die gerade zusehen -- und per Benachrichtigung
|
||||
@@ -434,6 +616,200 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
ANHÄNGE: FOTOS UND PDF (09.09.2026)
|
||||
|
||||
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
|
||||
schicken könnte. pdfs und fotos."
|
||||
|
||||
Wer im Gespräch schreiben darf, darf auch etwas schicken -- auch
|
||||
Creator. Das ist ausdrücklich ANDERS als in der Dateiablage, wo
|
||||
Creator nicht hochladen dürfen: Dort landet etwas in einem Bereich,
|
||||
den mehrere sehen; hier bekommt es genau der, mit dem man ohnehin
|
||||
gerade spricht. Ein Creator, der seinem Scout einen Screenshot
|
||||
schicken will, soll das können.
|
||||
|
||||
DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT. Die
|
||||
Mitgliedschaft wird VOR express.raw geprüft -- sonst wanderten
|
||||
zwölf Megabyte durch die Leitung, nur um danach verworfen zu
|
||||
werden, und jeder Angemeldete könnte damit an jeder Raumnummer
|
||||
Speicher verbrauchen.
|
||||
===================================================================== */
|
||||
|
||||
/** Vor express.raw: Bin ich in diesem Raum? */
|
||||
function drinFuerAnhang(req, res, next) {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
next();
|
||||
}
|
||||
|
||||
chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
|
||||
gleicheHerkunft,
|
||||
drinFuerAnhang,
|
||||
express.raw({ type: "*/*", limit: ANHANG_MAX }),
|
||||
(req, res) => {
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Buffer.isBuffer(req.body) || !req.body.length) {
|
||||
return res.status(400).json({ fehler: "Keine Datei empfangen." });
|
||||
}
|
||||
|
||||
/* DER TYP KOMMT AUS DEM INHALT, nicht aus dem Namen und nicht aus
|
||||
dem Content-Type. Siehe dateiErkennen(). */
|
||||
const erkannt = dateiErkennen(req.body);
|
||||
if (!erkannt) {
|
||||
return res.status(415).json({
|
||||
fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.",
|
||||
});
|
||||
}
|
||||
|
||||
let begleittext = "";
|
||||
try { begleittext = decodeURIComponent(req.get("x-text") || ""); } catch { begleittext = ""; }
|
||||
begleittext = begleittext.trim().slice(0, TEXT_MAX);
|
||||
|
||||
const name = anhangName(req.get("x-dateiname"), erkannt.endung);
|
||||
|
||||
/* Die Endung auf der Platte kommt aus der ERKENNUNG, nicht aus dem
|
||||
eingeschickten Namen. Damit kann dort nichts liegen, das anders
|
||||
heisst, als es ist. */
|
||||
mkdirSync(ANHANG_ORDNER, { recursive: true });
|
||||
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
|
||||
writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" });
|
||||
|
||||
const d = db();
|
||||
const n = jetzt();
|
||||
d.prepare(`INSERT INTO chat_nachrichten
|
||||
(raum_id, person_id, text, erstellt,
|
||||
anhang_datei, anhang_name, anhang_art, anhang_typ,
|
||||
anhang_groesse, anhang_breite, anhang_hoehe)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(raumId, req.person.id, begleittext, n,
|
||||
aufPlatte, name, erkannt.art, erkannt.typ,
|
||||
req.body.length, erkannt.breite || null, erkannt.hoehe || null);
|
||||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
|
||||
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(id, raumId, req.person.id);
|
||||
|
||||
const nachricht = {
|
||||
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
|
||||
rolle: req.person.rolle, text: begleittext, erstellt: n,
|
||||
zurueckgenommen: false, antwort: null,
|
||||
anhang: {
|
||||
art: erkannt.art, typ: erkannt.typ, name,
|
||||
groesse: req.body.length,
|
||||
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
|
||||
weg: `/workspace/api/chat/anhang/${id}`,
|
||||
},
|
||||
};
|
||||
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
|
||||
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
|
||||
|
||||
protokolliere("chat_anhang", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} ${erkannt.art} ${Math.round(req.body.length / 1024)} kB`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ nachricht });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Anhang:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Einen Anhang ansehen oder laden.
|
||||
*
|
||||
* Die Kennung ist die der NACHRICHT, nicht die der Datei. Damit
|
||||
* hängt der Zugriff an derselben Prüfung wie der Verlauf: Wer nicht
|
||||
* im Raum ist, bekommt 404 -- und wer das Gespräch weggeräumt hat,
|
||||
* auch. Ein eigener Dateischlüssel wäre eine zweite Tür mit einem
|
||||
* zweiten Schloss. */
|
||||
chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const n = db().prepare(`SELECT id, raum_id, weg_am,
|
||||
anhang_datei, anhang_name, anhang_art, anhang_typ
|
||||
FROM chat_nachrichten WHERE id = ?`).get(id);
|
||||
if (!n || !n.anhang_datei || n.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(n.raum_id, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (n.id <= geloeschtBis(n.raum_id, req.person.id)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
|
||||
const pfad = join(ANHANG_ORDNER, n.anhang_datei);
|
||||
try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
|
||||
|
||||
/* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein
|
||||
eingeschickter. Er steht seit dem Hochladen in der Datenbank und
|
||||
kann seitdem von niemandem mehr beeinflusst werden.
|
||||
|
||||
`nosniff` verbietet dem Browser, sich einen anderen Typ
|
||||
auszudenken. Die enge Richtlinie mit `sandbox` nimmt dem
|
||||
Dokument jede Ausführung -- das ist die Sicherung für den Fall,
|
||||
dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
|
||||
Bild kostet sie nichts. */
|
||||
res.setHeader("Content-Type", n.anhang_typ || "application/octet-stream");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
||||
res.setHeader("Cache-Control", "private, max-age=86400");
|
||||
/* Ansehen ist die Vorgabe -- ein Foto, das man erst herunterladen
|
||||
muss, um es zu sehen, ist kein Foto im Chat. `?laden=1` macht
|
||||
daraus einen Download. */
|
||||
const alsDownload = req.query.laden === "1";
|
||||
res.setHeader("Content-Disposition",
|
||||
`${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(n.anhang_name || "anhang")}`);
|
||||
createReadStream(pfad).pipe(res);
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Anhang ausliefern:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR (09.09.2026)
|
||||
|
||||
Filipe: "man muss die chats auch gelöscht bekommen!!!"
|
||||
Auf Nachfrage: nur bei mir.
|
||||
|
||||
Was hier NICHT passiert: Es wird nichts aus der Datenbank entfernt.
|
||||
Das Gegenüber behält seinen Verlauf vollständig -- niemand kann
|
||||
einem anderen die Unterhaltung wegnehmen. Und es wird auch nicht
|
||||
die Mitgliedschaft beendet: Schreibt der andere morgen wieder,
|
||||
kommt das Gespräch mit dem NEUEN zurück, das Alte bleibt weg.
|
||||
Genau so verhält sich jeder Messenger, den Filipe kennt.
|
||||
|
||||
`gelesen_bis` wandert mit. Ohne das käme das Gespräch sofort wieder
|
||||
als "ungelesen" hoch -- weggeräumt und trotzdem eine rote Zahl in
|
||||
der Kopfleiste wäre schlimmer als gar nicht löschen.
|
||||
===================================================================== */
|
||||
chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const d = db();
|
||||
const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?")
|
||||
.get(raumId).m;
|
||||
d.prepare(`UPDATE chat_teilnehmer
|
||||
SET geloescht_bis = ?, geloescht_am = ?,
|
||||
gelesen_bis = CASE WHEN gelesen_bis < ? THEN ? ELSE gelesen_bis END
|
||||
WHERE raum_id = ? AND person_id = ?`)
|
||||
.run(bis, jetzt(), bis, bis, raumId, req.person.id);
|
||||
|
||||
protokolliere("chat_weggeraeumt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `Raum #${raumId} bis #${bis}`,
|
||||
});
|
||||
res.json({ ok: true, bis });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Wegräumen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
SUCHE (09.09.2026, Punkt 15)
|
||||
|
||||
@@ -483,6 +859,7 @@ chatRouter.get("/workspace/api/chat/suche", (req, res) => {
|
||||
JOIN chat_raeume r ON r.id = n.raum_id
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
WHERE n.weg_am IS NULL
|
||||
AND n.id > t.geloescht_bis
|
||||
AND n.text LIKE ? ESCAPE '\\'
|
||||
ORDER BY n.id DESC LIMIT 60`).all(req.person.id, muster);
|
||||
|
||||
@@ -547,7 +924,8 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const n = db().prepare("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
const n = db().prepare(
|
||||
"SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
if (!n || !istDrin(n.raum_id, req.person.id)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
@@ -558,7 +936,25 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
|
||||
}
|
||||
|
||||
db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id);
|
||||
/* EIN ANHANG WIRD WIRKLICH GELÖSCHT, nicht nur ausgeblendet.
|
||||
|
||||
Beim Text reicht das Leeren -- er steht nur in der Zeile. Eine
|
||||
Datei liegt daneben auf der Platte und hätte ihre eigene Adresse
|
||||
behalten. "Zurückgenommen" hiesse dann: im Verlauf weg, über den
|
||||
Link weiter da. Das wäre keine Rücknahme, sondern der Anschein
|
||||
einer. Erst die Zeile leeren, dann die Datei -- scheitert das
|
||||
Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
|
||||
const dateiWeg = n.anhang_datei;
|
||||
db().prepare(`UPDATE chat_nachrichten
|
||||
SET text = '', weg_am = ?,
|
||||
anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
|
||||
anhang_typ = NULL, anhang_groesse = NULL,
|
||||
anhang_breite = NULL, anhang_hoehe = NULL
|
||||
WHERE id = ?`).run(jetzt(), id);
|
||||
if (dateiWeg) {
|
||||
try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
|
||||
catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
|
||||
}
|
||||
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
|
||||
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
|
||||
|
||||
@@ -626,7 +1022,9 @@ async function meldeNeueNachricht(personId, raumId, nachricht) {
|
||||
/* Der Anfang der Nachricht steht in der Meldung -- eine
|
||||
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
|
||||
Öffnen, um zu erfahren, ob es wichtig war. */
|
||||
text: String(nachricht.text || "").slice(0, 120),
|
||||
text: String(nachricht.text || "").slice(0, 120)
|
||||
|| (nachricht.anhang?.art === "bild" ? "Foto"
|
||||
: nachricht.anhang?.art === "pdf" ? "PDF" : ""),
|
||||
ziel: `/workspace/chat.html?raum=${raumId}`,
|
||||
});
|
||||
} catch (fehler) {
|
||||
@@ -679,7 +1077,8 @@ chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
||||
const z = db().prepare(`
|
||||
SELECT COUNT(*) AS n FROM chat_nachrichten m
|
||||
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`)
|
||||
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.id > t.geloescht_bis
|
||||
AND m.person_id <> ?`)
|
||||
.get(req.person.id, req.person.id);
|
||||
res.json({ ungelesen: z?.n || 0 });
|
||||
} catch (fehler) {
|
||||
@@ -688,6 +1087,28 @@ chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Fehler aus express.raw ---------------------------------------
|
||||
Ist die Datei zu gross, bricht express.raw ab, BEVOR der eigene Code
|
||||
laeuft -- das try/catch dort greift also nicht, und der Fehler landet
|
||||
im allgemeinen Behandler als HTTP 500. Fuer den Absender saehe das
|
||||
aus wie ein kaputter Server statt wie "Datei zu gross"; er wuerde es
|
||||
wieder und wieder versuchen. Derselbe Behandler steht aus demselben
|
||||
Grund in workspace-dateien.js.
|
||||
|
||||
Er muss ZULETZT stehen: Express erkennt ihn an den vier Parametern
|
||||
und ruft ihn nur fuer Fehler aus den Routen davor auf. */
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
chatRouter.use((fehler, req, res, next) => {
|
||||
if (!req.path.startsWith("/workspace/api/chat")) return next(fehler);
|
||||
if (fehler?.type === "entity.too.large" || fehler?.status === 413) {
|
||||
return res.status(413).json({
|
||||
fehler: `Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1024 / 1024)} MB je Datei.`,
|
||||
});
|
||||
}
|
||||
console.error("[chat] Unerwartet:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
});
|
||||
|
||||
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
|
||||
export function siehtZu(personId) {
|
||||
return (zuschauer.get(Number(personId)) || []).size > 0;
|
||||
|
||||
@@ -395,6 +395,50 @@ function umstellungen(d) {
|
||||
ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite
|
||||
Gelegenheit fuer eine Sperre. */
|
||||
["chat_nachrichten", "antwort_auf", "INTEGER"],
|
||||
|
||||
/* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
|
||||
|
||||
Filipe: "man muss die chats auch geloescht bekommen!!!"
|
||||
Auf Nachfrage entschieden: nur bei mir, nicht bei beiden. Das
|
||||
Gespraech verschwindet aus MEINER Liste, das Gegenueber behaelt
|
||||
seinen Verlauf. Sonst koennte jeder jedem die Unterhaltung
|
||||
vernichten -- und in einem Gespraech, in dem Absprachen stehen,
|
||||
waere das ein Loch, das niemand mehr fuellen kann.
|
||||
|
||||
ZWEI SPALTEN, NICHT EINE, und der Grund ist ein Randfall:
|
||||
`geloescht_bis` haelt fest, bis zu welcher Nachricht ich es nicht
|
||||
mehr sehen will. Bei einem Gespraech OHNE jede Nachricht waere
|
||||
das 0 -- und 0 heisst auch "nie geloescht". `geloescht_am`
|
||||
unterscheidet die beiden Faelle. Kommt spaeter etwas Neues
|
||||
(id > geloescht_bis), taucht das Gespraech von selbst wieder auf;
|
||||
das Alte bleibt weg. */
|
||||
["chat_teilnehmer", "geloescht_bis", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["chat_teilnehmer", "geloescht_am", "TEXT"],
|
||||
|
||||
/* ANHAENGE: FOTOS UND PDF (09.09.2026).
|
||||
|
||||
Filipe: "am besten waere es auch wenn man da auch im chat pdfs
|
||||
schicken koennte. pdfs und fotos."
|
||||
|
||||
Je Nachricht hoechstens ein Anhang -- deshalb Spalten und keine
|
||||
eigene Tabelle. Eine Tabelle waere die richtige Wahl fuer
|
||||
"mehrere je Nachricht"; die gibt es hier nicht, und eine leere
|
||||
Verbundabfrage bei jedem Verlauf waere Aufwand fuer einen Fall,
|
||||
den es nicht gibt.
|
||||
|
||||
`anhang_datei` ist der ERZEUGTE Name auf der Platte, niemals der
|
||||
eingeschickte. `anhang_art` ist "bild" oder "pdf" und wird aus
|
||||
den ersten Bytes der Datei bestimmt, nicht aus dem Namen und
|
||||
nicht aus dem Content-Type -- beide kommen vom Absender.
|
||||
Breite und Hoehe stehen dabei, damit der Verlauf beim Laden
|
||||
eines Bildes nicht springt. */
|
||||
["chat_nachrichten", "anhang_datei", "TEXT"],
|
||||
["chat_nachrichten", "anhang_name", "TEXT"],
|
||||
["chat_nachrichten", "anhang_art", "TEXT"],
|
||||
["chat_nachrichten", "anhang_typ", "TEXT"],
|
||||
["chat_nachrichten", "anhang_groesse", "INTEGER"],
|
||||
["chat_nachrichten", "anhang_breite", "INTEGER"],
|
||||
["chat_nachrichten", "anhang_hoehe", "INTEGER"],
|
||||
]) {
|
||||
try {
|
||||
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
|
||||
|
||||
@@ -53,11 +53,18 @@ scp -q dogfather-server:/home/dogiweb/workspace-daten/sicherungen/*.db "$ZIEL/"
|
||||
# achtzehn Seiten rot -- der Steckbrief, mit einem 404 fuer ein Bild.
|
||||
# Genau dafuer gibt es sie.
|
||||
#
|
||||
# CHAT-ANHAENGE GEHOEREN DAZU (ergaenzt 09.09.2026).
|
||||
#
|
||||
# Seit diesem Tag koennen im Chat Fotos und PDFs geschickt werden
|
||||
# (workspace-chat.js: chat-anhaenge/). Der Ordner wurde hier im selben
|
||||
# Zug eingetragen -- nicht spaeter, wenn es auffaellt: Genau so ist die
|
||||
# Luecke bei den Profilbildern entstanden.
|
||||
#
|
||||
# Wer hier einen Ordner ergaenzt, traegt ihn auch in ORDNER unten ein;
|
||||
# die Probe vergleicht die Liste gegen die Ordner, die der Server
|
||||
# wirklich benutzt, und schlaegt bei einem vergessenen an.
|
||||
echo "Hole hochgeladene Dateien, Bilder und die Bibliothek ..."
|
||||
ORDNER="dateien wissen profilbilder"
|
||||
echo "Hole hochgeladene Dateien, Bilder, Anhaenge und die Bibliothek ..."
|
||||
ORDNER="dateien wissen profilbilder chat-anhaenge"
|
||||
for o in $ORDNER; do
|
||||
mkdir -p "$ZIEL/$o"
|
||||
scp -qr "dogfather-server:/home/dogiweb/workspace-daten/$o/." "$ZIEL/$o/" 2>/dev/null || true
|
||||
|
||||
@@ -927,3 +927,152 @@
|
||||
@media (forced-colors: active) {
|
||||
.emoji-tafel, .chat__zum-neuesten { border: 1px solid CanvasText; background: Canvas; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
ANHÄNGE, ABLAGE UND WEGRÄUMEN (09.09.2026)
|
||||
|
||||
Filipe: "man muss die chats auch gelöscht bekommen!!! am besten wäre
|
||||
es auch wenn man da auch im chat pdfs schicken könnte. pdfs und
|
||||
fotos."
|
||||
===================================================================== */
|
||||
|
||||
/* Der Wegräum-Knopf sitzt im Kopf, rechts. Er ist absichtlich LEISE:
|
||||
Löschen ist die seltene Handlung, und ein roter Knopf, der jeden Tag
|
||||
danebensteht, wird irgendwann aus Versehen getroffen. Rot wird er
|
||||
erst beim Darüberfahren -- dann, wenn jemand ihn wirklich meint. */
|
||||
.chat__weg {
|
||||
flex: none; margin-left: auto;
|
||||
width: 34px; height: 34px;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid var(--rand); border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .03);
|
||||
color: var(--text-leise); cursor: pointer;
|
||||
transition: color var(--tempo), border-color var(--tempo);
|
||||
}
|
||||
.chat__weg svg {
|
||||
width: 17px; height: 17px;
|
||||
fill: none; stroke: currentColor; stroke-width: 1.7;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.chat__weg:hover { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
||||
.chat__weg:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
/* Steht der Knopf da, braucht der Text daneben seinen eigenen Rand --
|
||||
sonst hat .chat__kopf-text keine Grenze und schiebt ihn hinaus. */
|
||||
.chat__kopf--verlauf .chat__kopf-text { min-width: 0; flex: 1; }
|
||||
|
||||
/* ---- Die Büroklammer ------------------------------------------------ */
|
||||
.chat__anhang-feld {
|
||||
/* Nicht display:none -- ein verstecktes Feld lässt sich in manchen
|
||||
Browsern nicht mehr über click() öffnen. Es steht da, nur ohne
|
||||
Fläche. */
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip-path: inset(50%); border: 0;
|
||||
}
|
||||
.chat__anhang {
|
||||
flex: none;
|
||||
width: 40px; height: 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
appearance: none; border: 1px solid rgba(150, 186, 220, .18);
|
||||
border-radius: 11px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
color: var(--text-leise); cursor: pointer;
|
||||
transition: color var(--tempo), border-color var(--tempo);
|
||||
}
|
||||
.chat__anhang svg {
|
||||
width: 19px; height: 19px;
|
||||
fill: none; stroke: currentColor; stroke-width: 1.7;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.chat__anhang:hover { color: var(--text); border-color: rgba(150, 186, 220, .34); }
|
||||
.chat__anhang:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
.chat__anhang:disabled { opacity: .45; cursor: progress; }
|
||||
|
||||
/* ---- Ein Foto in der Blase ------------------------------------------ */
|
||||
/* Die Höhe ist begrenzt, die Breite folgt dem Bild. Ein Hochformat
|
||||
nähme sonst den halben Verlauf ein und schöbe alles darunter aus dem
|
||||
Bild -- man scrollt dann durch ein Foto statt durch ein Gespräch. */
|
||||
.chat-bild {
|
||||
display: block;
|
||||
width: 100%; /* die Hoechstbreite setzt das Skript */
|
||||
margin: 0 0 6px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
}
|
||||
/* Die Groesse macht der Kasten (aspect-ratio + max-width, siehe
|
||||
chat.js). Das Bild fuellt ihn nur aus. Stuende die Groesse hier am
|
||||
Bild, waere sie erst bekannt, wenn das Bild da ist -- und genau das
|
||||
war der Fehler, der den Verlauf um 323 px verschoben hat. */
|
||||
.chat-bild img {
|
||||
display: block;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
/* Ohne bekannte Masse gibt es kein Verhaeltnis, an dem der Kasten
|
||||
haengen koennte. Dann greift der alte Weg -- und der Zuhoerer auf
|
||||
`load` in chat.js haelt den Bildlauf unten. */
|
||||
.chat-bild:not([style*="aspect-ratio"]) img { height: auto; max-height: 340px; }
|
||||
.chat-bild:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
.chat-bild[data-kaputt="ja"] {
|
||||
line-height: 1.5; padding: 12px 14px;
|
||||
font-size: .84rem; color: var(--text-leise);
|
||||
}
|
||||
|
||||
/* ---- Ein PDF als Karte ---------------------------------------------- */
|
||||
.chat-datei {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
margin: 0 0 6px; padding: 10px 12px;
|
||||
border: 1px solid rgba(150, 186, 220, .2);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
color: inherit; text-decoration: none;
|
||||
transition: border-color var(--tempo), background var(--tempo);
|
||||
}
|
||||
.chat-datei:hover {
|
||||
border-color: rgba(150, 186, 220, .38);
|
||||
background: rgba(255, 255, 255, .07);
|
||||
}
|
||||
.chat-datei:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
|
||||
.chat-datei__zeichen {
|
||||
flex: none;
|
||||
width: 38px; height: 38px; border-radius: 10px;
|
||||
display: grid; place-items: center;
|
||||
font-size: .72rem; font-weight: 800; letter-spacing: .04em;
|
||||
color: #ff9aa2;
|
||||
background: rgba(255, 154, 162, .12);
|
||||
border: 1px solid rgba(255, 154, 162, .3);
|
||||
}
|
||||
.chat-datei__mitte { min-width: 0; display: grid; }
|
||||
.chat-datei__name {
|
||||
font-size: .86rem; font-weight: 600; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chat-datei__mass { font-size: .74rem; color: var(--text-leise); }
|
||||
|
||||
/* ---- Die Ablagefläche beim Hineinziehen ------------------------------ */
|
||||
.chat__ablage {
|
||||
position: absolute; inset: 8px;
|
||||
z-index: 6;
|
||||
display: grid; place-items: center;
|
||||
border: 2px dashed color-mix(in srgb, var(--akzent) 55%, transparent);
|
||||
border-radius: 16px;
|
||||
background: rgba(8, 12, 20, .82);
|
||||
color: var(--text);
|
||||
font-size: .95rem; font-weight: 650;
|
||||
pointer-events: none; /* sonst frisst sie das drop-Ereignis */
|
||||
}
|
||||
|
||||
/* ---- Zwischenstand statt Störung ------------------------------------ */
|
||||
/* "wird geschickt …" ist keine Fehlermeldung. Rot ist hier das
|
||||
Einzige, was Aufmerksamkeit verlangen darf -- wer es für
|
||||
Zwischenstände mitbenutzt, macht es wertlos. */
|
||||
.fehler[data-art="lauf"] { color: var(--text-leise); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat__weg, .chat__anhang, .chat-datei { transition: none; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.chat-datei, .chat__ablage { border: 1px solid CanvasText; }
|
||||
}
|
||||
|
||||
+302
-3
@@ -87,7 +87,16 @@
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||||
/* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und
|
||||
einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu
|
||||
zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt --
|
||||
und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf.
|
||||
Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */
|
||||
const melde = (t, art = 'fehler') => {
|
||||
const z = $('fehler');
|
||||
z.textContent = t || '';
|
||||
z.dataset.art = t ? art : 'fehler';
|
||||
};
|
||||
|
||||
/* ---------- Die Gesprächsliste ------------------------------------------- */
|
||||
|
||||
@@ -277,6 +286,8 @@
|
||||
$('schreiben').hidden = false;
|
||||
$('verlauf-leer').hidden = true;
|
||||
$('chat').dataset.ansicht = 'verlauf';
|
||||
/* Erst jetzt gibt es etwas zum Wegräumen. */
|
||||
$('gespraech-weg').hidden = false;
|
||||
|
||||
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
|
||||
das Letzte sehen, nicht das Erste. */
|
||||
@@ -411,10 +422,17 @@
|
||||
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
|
||||
'Nachricht zurückgenommen'));
|
||||
} else {
|
||||
/* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
|
||||
das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
|
||||
if (n.anhang) blase.append(anhangBauen(n.anhang));
|
||||
/* textContent, nie innerHTML: Eine Nachricht darf keine
|
||||
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
|
||||
(white-space: pre-wrap). */
|
||||
blase.append(mitLinks(n.text));
|
||||
(white-space: pre-wrap).
|
||||
|
||||
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
|
||||
einem Foto besteht, hätte sonst einen leeren Absatz darunter
|
||||
-- und der nimmt Platz, den niemand erklären kann. */
|
||||
if (n.text) blase.append(mitLinks(n.text));
|
||||
}
|
||||
|
||||
const fuss = el('div', 'chat-nachricht__fuss');
|
||||
@@ -497,6 +515,111 @@
|
||||
: 'Zum neuesten';
|
||||
}
|
||||
|
||||
/* ---------- Anhänge: Fotos und PDF (09.09.2026) -------------------------
|
||||
Filipe: "pdfs und fotos".
|
||||
|
||||
Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein
|
||||
Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
|
||||
ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein
|
||||
grauer Kasten, der so tut, als könnte man etwas lesen.
|
||||
|
||||
`width`/`height` stehen am Bild, obwohl das CSS die Größe macht.
|
||||
Sie sind das Seitenverhältnis: Damit hält der Browser den Platz
|
||||
frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf,
|
||||
sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn
|
||||
man gerade anfängt zu lesen. */
|
||||
function grosse(bytes) {
|
||||
const b = Number(bytes) || 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function anhangBauen(a) {
|
||||
if (a.art === 'bild') {
|
||||
const kasten = el('a', 'chat-bild');
|
||||
kasten.href = a.weg;
|
||||
kasten.target = '_blank';
|
||||
kasten.rel = 'noopener noreferrer';
|
||||
kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`;
|
||||
|
||||
/* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD.
|
||||
------------------------------------------------------------------
|
||||
Erster Anlauf: width- und height-Attribut ans <img>, wie man es
|
||||
überall liest. Gemessen war der Verlauf danach 323 px zu hoch --
|
||||
genau die Höhe des Fotos.
|
||||
|
||||
Der Grund: Die Attribute geben dem Browser nur das SEITEN-
|
||||
VERHÄLTNIS. Damit daraus eine Höhe wird, muss die Breite
|
||||
feststehen. Bei `width: auto` und einem noch nicht geladenen
|
||||
Bild ist sie 0 -- das Verhältnis hat nichts, womit es rechnen
|
||||
könnte, der Kasten ist 0 px hoch, und wenn das Foto ankommt,
|
||||
schiebt es alles darunter weg. Man springt beim Öffnen ans
|
||||
Ende und steht eine Sekunde später mittendrin.
|
||||
|
||||
Deshalb bekommt der KASTEN das Verhältnis und eine Höchstbreite
|
||||
(die Breite, die das Bild nach dem Deckel von 340 px Höhe
|
||||
hätte). Damit steht die Breite von Anfang an fest, die Höhe
|
||||
folgt daraus, und es wackelt nichts.
|
||||
|
||||
Gefunden hat das keine Überlegung, sondern eine Zahl: In der
|
||||
Prüfung war der Rest 0 px -- dort lag das Bild schon im
|
||||
Zwischenspeicher. Erst die Messung im Aufnahmeskript, beim
|
||||
ERSTEN Laden, zeigte die 323. */
|
||||
if (a.breite > 0 && a.hoehe > 0) {
|
||||
const DECKEL = 340;
|
||||
const breit = a.hoehe > DECKEL ? Math.round(a.breite * DECKEL / a.hoehe) : a.breite;
|
||||
kasten.style.aspectRatio = `${a.breite} / ${a.hoehe}`;
|
||||
kasten.style.maxWidth = `${breit}px`;
|
||||
}
|
||||
|
||||
const bild = document.createElement('img');
|
||||
bild.src = a.weg;
|
||||
bild.alt = a.name || 'Foto';
|
||||
bild.decoding = 'async';
|
||||
if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; }
|
||||
/* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach
|
||||
unten springt, sind die sichtbaren Bilder die zuletzt
|
||||
geschriebenen -- lazy verzögert genau die, die man sehen will,
|
||||
und spart nichts, weil ein Chat selten hundert Fotos hat.
|
||||
|
||||
WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts
|
||||
reserviert. Dann ist dieser Zuhörer die Sicherung: War man
|
||||
unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht
|
||||
beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den
|
||||
Zustand längst umgeworfen. */
|
||||
const warUnten = unten;
|
||||
bild.addEventListener('load', () => {
|
||||
if (!warUnten) return;
|
||||
const v = $('verlauf');
|
||||
v.scrollTop = v.scrollHeight;
|
||||
unten = true; neuUnten = 0;
|
||||
springKnopfZeigen();
|
||||
});
|
||||
/* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes
|
||||
Symbol ohne Erklärung. */
|
||||
bild.addEventListener('error', () => {
|
||||
kasten.dataset.kaputt = 'ja';
|
||||
kasten.textContent = 'Foto lässt sich gerade nicht laden';
|
||||
});
|
||||
kasten.append(bild);
|
||||
return kasten;
|
||||
}
|
||||
|
||||
const karte = el('a', 'chat-datei');
|
||||
karte.href = a.weg;
|
||||
karte.target = '_blank';
|
||||
karte.rel = 'noopener noreferrer';
|
||||
const zeichen = el('span', 'chat-datei__zeichen', 'PDF');
|
||||
zeichen.setAttribute('aria-hidden', 'true');
|
||||
const mitte = el('span', 'chat-datei__mitte');
|
||||
mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument'));
|
||||
mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`));
|
||||
karte.append(zeichen, mitte);
|
||||
karte.title = `${a.name} · ${grosse(a.groesse)}`;
|
||||
return karte;
|
||||
}
|
||||
|
||||
/* ---------- Links in Nachrichten (09.09.2026) ---------------------------
|
||||
Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
|
||||
ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
|
||||
@@ -1064,6 +1187,182 @@
|
||||
history.replaceState(null, '', 'chat.html');
|
||||
});
|
||||
|
||||
/* =======================================================================
|
||||
ANHÄNGE SCHICKEN (09.09.2026)
|
||||
|
||||
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
|
||||
schicken könnte. pdfs und fotos."
|
||||
|
||||
DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
|
||||
die Büroklammer (immer da, auch am Handy), Hineinziehen (am
|
||||
Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
|
||||
einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
|
||||
als Datei).
|
||||
|
||||
Alle drei landen in derselben Funktion. Drei Fassungen desselben
|
||||
Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
|
||||
vergisst.
|
||||
|
||||
GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
|
||||
tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
|
||||
das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
|
||||
die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
|
||||
const ANHANG_MAX = 12 * 1024 * 1024;
|
||||
const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
|
||||
let schicktGerade = false;
|
||||
|
||||
async function anhangSchicken(datei) {
|
||||
if (!offen || !datei) return;
|
||||
if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
|
||||
if (!ANHANG_TYPEN.has(datei.type)) {
|
||||
melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.');
|
||||
return;
|
||||
}
|
||||
if (datei.size > ANHANG_MAX) {
|
||||
melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`);
|
||||
return;
|
||||
}
|
||||
|
||||
schicktGerade = true;
|
||||
$('anhang-auf').disabled = true;
|
||||
melde(`„${datei.name}“ wird geschickt …`, 'lauf');
|
||||
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
|
||||
das Bild anhängt, will beides in einer Nachricht. */
|
||||
const begleittext = $('text').value.trim();
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': datei.type,
|
||||
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
|
||||
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
|
||||
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
|
||||
'X-Text': encodeURIComponent(begleittext),
|
||||
},
|
||||
body: datei,
|
||||
});
|
||||
if (!a.ok) {
|
||||
let grund = 'Ging nicht.';
|
||||
try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
|
||||
melde(grund);
|
||||
return;
|
||||
}
|
||||
const { nachricht } = await a.json();
|
||||
/* Nur eintragen, wenn sie nicht schon über den Strom kam --
|
||||
sonst stünde dasselbe Foto zweimal da. */
|
||||
if (!nachrichten.some((x) => x.id === nachricht.id)) {
|
||||
nachrichten.push({ ...nachricht, selbst: true });
|
||||
}
|
||||
$('text').value = '';
|
||||
entwurfWeg(offen.id);
|
||||
hoeheAnpassen();
|
||||
zaehlerZeigen();
|
||||
unten = true; neuUnten = 0;
|
||||
verlaufZeichnen();
|
||||
melde('');
|
||||
raeumeLaden();
|
||||
} catch {
|
||||
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
|
||||
} finally {
|
||||
schicktGerade = false;
|
||||
$('anhang-auf').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('anhang-auf').addEventListener('click', () => $('anhang-feld').click());
|
||||
$('anhang-feld').addEventListener('change', () => {
|
||||
const datei = $('anhang-feld').files?.[0];
|
||||
/* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei
|
||||
beim zweiten Mal kein `change` aus, und man wundert sich, warum
|
||||
nichts passiert. */
|
||||
$('anhang-feld').value = '';
|
||||
if (datei) void anhangSchicken(datei);
|
||||
});
|
||||
|
||||
/* ---- Hineinziehen ------------------------------------------------------
|
||||
`dragover` MUSS abgefangen werden, sonst öffnet der Browser die
|
||||
Datei einfach als Seite -- der Verlauf wäre weg und das Bild
|
||||
bildschirmfüllend da. */
|
||||
{
|
||||
const flaeche = document.querySelector('.chat__spalte--verlauf');
|
||||
let tiefe = 0; /* dragenter/-leave feuert je Kind */
|
||||
const zeigen = (ja) => {
|
||||
const k = $('ablage');
|
||||
if (k) k.hidden = !ja || !offen;
|
||||
};
|
||||
flaeche.addEventListener('dragenter', (e) => {
|
||||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||||
tiefe++; zeigen(true);
|
||||
});
|
||||
flaeche.addEventListener('dragover', (e) => {
|
||||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } });
|
||||
flaeche.addEventListener('drop', (e) => {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
tiefe = 0; zeigen(false);
|
||||
if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; }
|
||||
void anhangSchicken(e.dataTransfer.files[0]);
|
||||
});
|
||||
/* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht
|
||||
öffnen. */
|
||||
window.addEventListener('dragover', (e) => e.preventDefault());
|
||||
window.addEventListener('drop', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
/* ---- Einfügen (Strg+V) --------------------------------------------------
|
||||
Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen --
|
||||
deshalb bekommt es hier einen mit Datum, sonst hiesse jede
|
||||
Bildschirmaufnahme "image.png". */
|
||||
$('text').addEventListener('paste', (e) => {
|
||||
const stuecke = [...(e.clipboardData?.items || [])];
|
||||
const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type));
|
||||
if (!bild) return; /* normaler Text: nichts tun */
|
||||
const datei = bild.getAsFile();
|
||||
if (!datei) return;
|
||||
e.preventDefault();
|
||||
const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
|
||||
const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-');
|
||||
void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png'
|
||||
? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type }));
|
||||
});
|
||||
|
||||
/* =======================================================================
|
||||
GESPRÄCH WEGRÄUMEN — NUR BEI MIR
|
||||
|
||||
Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich
|
||||
löschen?" liesse offen, ob der andere seinen Verlauf verliert --
|
||||
und das ist genau die Frage, die man in dem Moment hat. */
|
||||
$('gespraech-weg').addEventListener('click', async () => {
|
||||
if (!offen) return;
|
||||
const wer = offen.name;
|
||||
if (!window.confirm(
|
||||
`Gespräch mit ${wer} wegräumen?\n\n`
|
||||
+ 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt '
|
||||
+ 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch '
|
||||
+ 'mit dem Neuen zurück – das Alte bleibt weg.')) return;
|
||||
try {
|
||||
const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Ging nicht.'); return; }
|
||||
entwurfWeg(offen.id);
|
||||
offen = null;
|
||||
nachrichten = [];
|
||||
$('schreiben').hidden = true;
|
||||
$('gespraech-weg').hidden = true;
|
||||
$('verlauf-titel').textContent = 'Wähle ein Gespräch';
|
||||
$('verlauf-unter').textContent = '';
|
||||
$('verlauf-leer').hidden = false;
|
||||
$('chat').dataset.ansicht = 'liste';
|
||||
verlaufZeichnen();
|
||||
history.replaceState(null, '', 'chat.html');
|
||||
await raeumeLaden();
|
||||
melde('');
|
||||
} catch { melde('Keine Verbindung.'); }
|
||||
});
|
||||
|
||||
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
|
||||
dass jetzt wirklich jemand hinsieht. */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -231,10 +231,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -140,10 +140,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-14
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -149,14 +149,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -82,11 +82,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+42
-10
@@ -14,17 +14,17 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
|
||||
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
|
||||
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
|
||||
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
|
||||
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
|
||||
Dialog zu sehen bekam. -->
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -112,6 +112,18 @@
|
||||
<h2 class="chat__titel" id="verlauf-titel">Wähle ein Gespräch</h2>
|
||||
<p class="chat__unter" id="verlauf-unter"></p>
|
||||
</div>
|
||||
<!-- GESPRÄCH WEGRÄUMEN (09.09.2026).
|
||||
Filipe: "man muss die chats auch gelöscht bekommen!!!"
|
||||
Es verschwindet nur bei DIR -- das Gegenüber behält seinen
|
||||
Verlauf. Der Knopf steht erst da, wenn auch ein Gespräch
|
||||
offen ist; sonst wäre er ein Angebot ohne Gegenstand. -->
|
||||
<button type="button" class="chat__weg" id="gespraech-weg" hidden
|
||||
aria-label="Dieses Gespräch wegräumen"
|
||||
title="Gespräch wegräumen – nur bei dir">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 7h14M10 7V5h4v2M6.5 7l.8 12h9.4l.8-12M10 10.5v5.5M14 10.5v5.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- aria-live="polite": Ein Vorleseprogramm sagt neue Nachrichten
|
||||
@@ -121,6 +133,13 @@
|
||||
mitten im Satz weggerissen. Jetzt bleibt er stehen, und hier
|
||||
steht, wie viel unten wartet. -->
|
||||
<button type="button" class="chat__zum-neuesten" id="zum-neuesten" hidden></button>
|
||||
<!-- HINEINZIEHEN (09.09.2026). Erscheint nur, solange wirklich
|
||||
etwas über dem Fenster hängt. Eine dauerhaft sichtbare
|
||||
Ablagefläche wäre jeden Tag im Weg für den Fall, dass man
|
||||
sie vielleicht braucht. -->
|
||||
<div class="chat__ablage" id="ablage" hidden aria-hidden="true">
|
||||
<span>Loslassen – Foto oder PDF schicken</span>
|
||||
</div>
|
||||
<div class="chat__verlauf" id="verlauf" role="log" aria-live="polite" aria-relevant="additions">
|
||||
<p class="chat__leer" id="verlauf-leer">
|
||||
Links ein Gespräch auswählen – oder mit <strong>Neu</strong> eines anfangen.
|
||||
@@ -160,6 +179,19 @@
|
||||
nicht, sonst käme man nie zu einer zweiten Zeile.
|
||||
Die Tastenbelegung steht jetzt im title -- sie ist ein
|
||||
Hinweis für Wiederkehrer, keine Aufforderung. -->
|
||||
<!-- ANHANG (09.09.2026): "pdfs und fotos".
|
||||
Das Feld selbst bleibt unsichtbar -- ein Datei-Auswahlfeld
|
||||
sieht in jedem Browser anders aus und lässt sich nicht
|
||||
gestalten. Bedient wird es über die Büroklammer daneben. -->
|
||||
<input type="file" id="anhang-feld" class="chat__anhang-feld"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,application/pdf"
|
||||
tabindex="-1" aria-hidden="true" />
|
||||
<button type="button" class="chat__anhang" id="anhang-auf"
|
||||
aria-label="Foto oder PDF anhängen" title="Foto oder PDF anhängen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11.5 12.3 19.2a4.4 4.4 0 0 1-6.2-6.2l7.9-7.9a2.9 2.9 0 0 1 4.1 4.1l-7.9 7.9a1.5 1.5 0 0 1-2.1-2.1l7.2-7.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<textarea id="text" rows="1" maxlength="4000"
|
||||
placeholder="Nachricht schreiben …"
|
||||
title="Enter schickt ab, Umschalt+Enter macht eine neue Zeile"></textarea>
|
||||
@@ -215,10 +247,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609091211"></script>
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211"></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211"></script>
|
||||
<script src="assets/js/chat.js?v=202609091211"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251"></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251"></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251"></script>
|
||||
<script src="assets/js/chat.js?v=202609091251"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -172,12 +172,12 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/content.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/content.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -101,10 +101,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -401,6 +401,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -340,11 +340,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -217,10 +217,10 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211"></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211"></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211"></script>
|
||||
<script src="assets/js/leistung.js?v=202609091211"></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251"></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251"></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251"></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251"></script>
|
||||
<script src="assets/js/leistung.js?v=202609091251"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -335,10 +335,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -207,11 +207,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -99,11 +99,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/report.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/report.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -132,10 +132,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
|
||||
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -667,10 +667,10 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/start.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/start.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -78,11 +78,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -119,10 +119,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,13 +14,13 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
|
||||
Dialog "Creator anlegen". -->
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -142,11 +142,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -14,11 +14,11 @@
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091211" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609091251" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202609091251" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -183,10 +183,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609091211" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202609091251" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609091251" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user