diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs index 50cf09d6..ccef972f 100644 --- a/server/pruef-chat-anhaenge.mjs +++ b/server/pruef-chat-anhaenge.mjs @@ -84,7 +84,21 @@ d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)" d.close(); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); -const browser = await pw.chromium.launch(); +/* EIN ECHTES MIKROFON, NUR ERFUNDEN (23.09.2026). + + Fuer Sprachnachrichten braucht diese Pruefung eine Tonquelle. + Chromium bringt eine mit: `--use-fake-device-for-media-stream` + liefert einen gleichmaessigen Ton, `--use-fake-ui-for-media-stream` + beantwortet die Nachfrage nach der Erlaubnis mit Ja. + + WARUM NICHT EINE SELBST GEBAUTE BYTEFOLGE: Aus demselben Grund, der + zwanzig Zeilen weiter unten beim PNG steht. Eine gebastelte + WebM-Kopfzeile wuerde beweisen, dass meine Erkennung meine Bastelei + versteht. Was hier hochgeht, hat dagegen MediaRecorder erzeugt -- + dasselbe Stueck Programm, das auf Filipes Handy laeuft. */ +const browser = await pw.chromium.launch({ + args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"], +}); async function alsRolle(rolle, code, breite = 1280) { const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } }); @@ -259,6 +273,59 @@ let bildNachrichtId = 0; 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`); + + /* ---- ÜBER DEN ECHTEN KNOPF, NICHT ÜBER DIE SCHNITTSTELLE --------- + + ================================================================ + WARUM DAS HIER STEHT: EIN STILLER FEHLER, EINE LÜCKE IN DIESER + PRÜFUNG (23.09.2026) + ================================================================ + + Alles oben schickt mit `fetch` an die Route -- der bequeme Weg, + und er prüft den Server gründlich. Er prüft aber NICHT den Weg, + den ein Mensch geht: Büroklammer, Datei wählen, warten. + + In genau diesem Weg stand seit dem 19.09. `const { nachricht } = + e.daten;` -- ein `e`, das es dort nicht gibt. Die Zeile warf, der + `catch` daneben fing, und der Absender las: „Keine Verbindung -- + der Anhang wurde nicht geschickt." Das Foto war aber da. Es kam + Sekunden später über den Ereignisstrom, mit einer roten Meldung + darüber, die das Gegenteil behauptete, und der getippte + Begleitsatz blieb im Feld stehen -- also schickt man es noch + einmal. + + Diese Prüfung war die ganze Zeit grün. Sie hat den Server + geprüft, nicht die Bedienung. Deshalb geht dieser Block jetzt + durch das Dateifeld selbst und sieht auf das, was der Mensch + danach sieht: steht dort eine Fehlermeldung, und ist das Feld + leer? */ + await luna.seite.fill("#text", "über den Knopf geschickt"); + await luna.seite.setInputFiles("#anhang-feld", { + name: "ueber-den-knopf.png", + mimeType: "image/png", + /* Das kleinste gültige PNG: 1×1, ein Pixel. Es geht hier nicht um + das Bild, sondern um den Weg. */ + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"), + }); + await luna.seite.waitForTimeout(2500); + const danach = await luna.seite.evaluate(() => ({ + fehlertext: document.getElementById("fehler")?.textContent.trim() || "", + feld: document.getElementById("text")?.value || "", + letzterName: document.querySelector(".chat-nachricht:last-of-type .chat-bild") + ?.getAttribute("title") || "", + })); + ok(danach.letzterName.includes("ueber-den-knopf.png"), + `über die Büroklammer geschickt: das Foto steht im Verlauf (${danach.letzterName.slice(0, 40)})`); + /* DIE EIGENTLICHE AUSSAGE. Eine Fehlermeldung über einem Foto, das + angekommen ist, ist schlimmer als gar keine Rückmeldung. */ + ok(danach.fehlertext === "", + ` und darüber steht KEINE Fehlermeldung ("${danach.fehlertext}")`); + /* UND DAS FELD IST LEER. Bleibt der Begleitsatz stehen, schickt man + dasselbe Foto ein zweites Mal -- der Satz stand vorher drin. */ + ok(danach.feld === "", + ` das Schreibfeld ist leer, der Begleitsatz ging mit ("${danach.feld}")`); } /* ======================================================================= @@ -592,7 +659,182 @@ melde("\n=== 8. Das leere Gespräch ==="); /* ======================================================================= 9. KEINE STILLEN FEHLER ======================================================================= */ -melde("\n=== 9. Stille Fehler ==="); +/* ======================================================================= + SPRACHNACHRICHTEN (23.09.2026) + + Filipe: „dan will ich auch dass man im chat auch sprachnachrichten + und gifts reinschicken kann. also nur die modis rechte linke hand + und dogfather." + + ZWEI FRAGEN, UND DIE ZWEITE IST DIE, DIE ER AUSDRUECKLICH GESTELLT + HAT: + 1. Geht es ueberhaupt -- aufnehmen, hochladen, abspielen? + 2. Geht es fuer die anderen NICHT? Ohne diesen Teil stuende sein + „also nur" bloss im Kommentar. + + AUFGENOMMEN WIRD MIT MediaRecorder, im echten Browser, ueber den + echten Knopf. Damit prueft dieser Abschnitt nebenbei etwas, das + keine gebastelte Datei pruefen koennte: ob die Erkennung im Server + das versteht, was ein Browser tatsaechlich erzeugt. + ======================================================================= */ +melde("\n=== 9. Eine Sprachnachricht ==="); + +/** Im Browser aufnehmen und die Bytes als Base64 zurueckgeben. + * Fuer die Gegenprobe gebraucht: Patrick soll GENAU DIESELBE Art + * Datei schicken wollen wie DogFather -- sonst bewiese eine Absage + * nur, dass seine Datei kaputt war. */ +async function tonAufnehmen(seite, ms) { + return seite.evaluate(async (dauer) => { + const spur = await navigator.mediaDevices.getUserMedia({ audio: true }); + const typ = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg", "audio/mp4"] + .find((t) => window.MediaRecorder.isTypeSupported(t)) || ""; + const g = new MediaRecorder(spur, typ ? { mimeType: typ } : undefined); + const stuecke = []; + g.addEventListener("dataavailable", (e) => { if (e.data?.size) stuecke.push(e.data); }); + const fertig = new Promise((r) => g.addEventListener("stop", r)); + g.start(200); + await new Promise((r) => setTimeout(r, dauer)); + g.stop(); + await fertig; + spur.getTracks().forEach((t) => t.stop()); + const brocken = new Blob(stuecke, { type: g.mimeType || typ }); + const puffer = new Uint8Array(await brocken.arrayBuffer()); + let roh = ""; + for (const byte of puffer) roh += String.fromCharCode(byte); + return { b64: btoa(roh), typ: brocken.type, bytes: puffer.length }; + }, ms); +} + +{ + await oeffne(dogi.seite, raumDogi); + + /* ---- Der Knopf ist da -- und bei Patrick nicht ------------------ */ + const knopfDogi = await dogi.seite.locator("#ton-auf").isVisible(); + ok(knopfDogi, "DogFather sieht den Mikrofonknopf"); + await oeffne(pat.seite, raumId); + const knopfPat = await pat.seite.locator("#ton-auf").isVisible(); + /* DIE GEGENPROBE AM BILDSCHIRM. Ein Knopf, der da ist und dann + abgelehnt wird, ist schlimmer als keiner. */ + ok(!knopfPat, "der Scout sieht ihn nicht — „also nur die modis rechte linke hand und dogfather“"); + + /* ---- Aufnehmen ueber den echten Knopf --------------------------- */ + const vorher = await dogi.seite.locator(".chat-nachricht").count(); + await dogi.seite.click("#ton-auf"); + await dogi.seite.waitForSelector("#tonleiste:not([hidden])", { timeout: 8000 }); + ok(true, "der Knopf startet die Aufnahme, die Leiste erscheint"); + await dogi.seite.waitForTimeout(2600); + const zeit = (await dogi.seite.locator("#ton-zeit").innerText()).trim(); + /* DIE ZEIT MUSS LAUFEN. Stuende sie still, wuesste niemand, ob noch + aufgenommen wird -- und man redet in eine Aufnahme, die steht. */ + ok(/^0:0[2-9]$/.test(zeit), `die Zeit läuft mit (${zeit})`); + await dogi.seite.click("#ton-senden"); + await dogi.seite.waitForTimeout(3000); + + const nachher = await dogi.seite.locator(".chat-nachricht").count(); + ok(nachher === vorher + 1, `die Sprachnachricht steht im Verlauf (${vorher} -> ${nachher})`); + ok(!(await dogi.seite.locator("#tonleiste").isVisible()), + " und die Aufnahmeleiste ist wieder zu"); + + const blase = await dogi.seite.evaluate(() => { + const k = document.querySelector(".chat-ton"); + if (!k) return null; + const g = k.querySelector("audio"); + return { + marke: k.querySelector(".chat-ton__marke")?.textContent.trim() || "", + dauer: k.querySelector(".chat-ton__dauer")?.textContent.trim() || "", + quelle: g?.getAttribute("src") || "", + bedienbar: !!g?.controls, + vorladen: g?.getAttribute("preload") || "", + vorlesewort: g?.getAttribute("aria-label") || "", + breite: Math.round(k.getBoundingClientRect().width), + }; + }); + ok(!!blase, "sie ist als Sprachnachricht gezeichnet"); + ok(blase?.marke === "Sprachnachricht", ` beschriftet: ${blase?.marke}`); + ok(/^0:0[2-9]$/.test(blase?.dauer || ""), ` mit ihrer Länge (${blase?.dauer})`); + ok(blase?.bedienbar === true, " mit dem Abspielgerät des Browsers, nicht nachgebaut"); + ok(blase?.vorladen === "metadata", + " das nur den Kopf lädt — zehn Sprachnachrichten kosten so Kilobyte statt Megabyte"); + ok(/Sprachnachricht/.test(blase?.vorlesewort || ""), + ` und ein Vorleseprogramm sagt mehr als „Audio" (${blase?.vorlesewort})`); + ok((blase?.breite || 0) >= 200, + ` breit genug, um den Schieber zu treffen (${blase?.breite} px)`); + + /* ---- Was steht in der Datenbank? -------------------------------- */ + const db2 = new DatabaseSync(process.env.WORKSPACE_DB); + const zeile = db2.prepare(`SELECT anhang_art, anhang_typ, anhang_groesse, anhang_dauer + FROM chat_nachrichten WHERE anhang_art = 'ton' ORDER BY id DESC LIMIT 1`).get(); + db2.close(); + ok(zeile?.anhang_art === "ton", `erkannt als ${zeile?.anhang_art}`); + /* DER TYP KOMMT AUS DEN ERSTEN BYTES, nicht aus dem Content-Type -- + und das ist hier die eigentliche Aussage: Die Erkennung versteht, + was ein echter Browser erzeugt. */ + ok(/^audio\/(webm|ogg|mp4)$/.test(zeile?.anhang_typ || ""), + ` und der Typ kommt aus dem Inhalt (${zeile?.anhang_typ})`); + ok((zeile?.anhang_groesse || 0) > 500, ` ${zeile?.anhang_groesse} Bytes sind angekommen`); + ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000, + ` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`); + + /* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */ + const vorschau = await dogi.seite.evaluate(async () => { + const a = await fetch("/workspace/api/chat/raeume"); + const j = await a.json(); + return (j.raeume || []).map((r) => r.letzte?.text || ""); + }); + ok(vorschau.includes("Sprachnachricht"), + `in der Gesprächsliste steht „Sprachnachricht" statt einer leeren Zeile`); + + /* ---- DIE GEGENPROBE: derselbe Ton, ein anderer Absender ---------- */ + const aufnahme = await tonAufnehmen(pat.seite, 1500); + ok(aufnahme.bytes > 500, `Patrick nimmt dieselbe Art Datei auf (${aufnahme.bytes} Bytes)`); + const absage = await absichtlich([pat], () => pat.seite.evaluate(async ([r, b64, typ]) => { + const roh = atob(b64); + const puffer = new Uint8Array(roh.length); + for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i); + const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, { + method: "POST", + headers: { "Content-Type": typ, "X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "1500" }, + body: puffer, + }); + return { status: a.status, text: (await a.json())?.fehler || "" }; + }, [raumId, aufnahme.b64, aufnahme.typ])); + /* ER GEHT AN DER OBERFLAECHE VORBEI -- genau darum geht es. Der + ausgeblendete Knopf ist die Hoeflichkeit, diese Antwort ist die + Schranke. */ + ok(absage.status === 403, + `am Knopf vorbei geschickt: abgelehnt (HTTP ${absage.status})`); + ok(/Team Dogi/.test(absage.text), + ` mit einem Satz, der sagt warum ("${absage.text}")`); + const db3 = new DatabaseSync(process.env.WORKSPACE_DB); + const vonPat = db3.prepare( + "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE anhang_art = 'ton' AND person_id = ?") + .get(idPat).n; + db3.close(); + ok(vonPat === 0, ` und nichts davon ist gespeichert (${vonPat})`); + + /* ---- Und ein Video im Tonbehälter geht auch nicht ---------------- */ + const alsVideo = await absichtlich([dogi], () => dogi.seite.evaluate(async (r) => { + /* Eine WebM-Kopfzeile mit einer VIDEO-Spur. Hier ist Basteln + ausnahmsweise richtig: Es geht nicht darum, ob echtes Video + erkannt wird, sondern ob die Spurkennung ueberhaupt angesehen + wird -- und das laesst sich nur mit einer Datei pruefen, die + genau daran scheitern muss. */ + const kopf = [0x1a, 0x45, 0xdf, 0xa3]; + const wort = [..."V_VP9"].map((c) => c.charCodeAt(0)); + const puffer = new Uint8Array([...kopf, ...new Array(20).fill(0x42), ...wort, + ...new Array(200).fill(0x11)]); + const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, { + method: "POST", + headers: { "Content-Type": "audio/webm", "X-Dateiname": "nicht-ton.webm" }, + body: puffer, + }); + return a.status; + }, raumDogi)); + ok(alsVideo === 415, + `ein Video im Ton-Behälter wird abgelehnt (HTTP ${alsVideo}) — sonst liefe es ohne Bild und niemand wüsste es`); +} + +melde("\n=== 10. Stille Fehler ==="); { /* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT. diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 7f5af58c..a6ec751b 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -77,6 +77,24 @@ const ANHANG_MAX = 12 * 1024 * 1024; const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge"); const ANHANG_NAME_MAX = 200; +/* EIGENE GRENZE FUER TON (23.09.2026). + + Zwoelf Megabyte sind fuer ein Foto richtig und fuer eine + Sprachnachricht sinnlos: Bei Opus mit 32 kbit/s waeren das rund + fuenfzig Minuten am Stueck. Vier Megabyte reichen fuer etwa zwanzig + Minuten -- mehr als genug fuer „hoer mal kurz" und immer noch eine + echte Schranke. + + SIE WIRD NACH DEM EMPFANG GEPRUEFT, und das ist kein Versehen: Ob + etwas Ton ist, sagen erst die ersten Bytes. Vorher weiss niemand, + welche Grenze gilt. Gespeichert wird trotzdem nichts -- abgelehnt + wird, bevor die Datei die Platte sieht. + + DIE LAENGE IN MINUTEN STEHT IM BROWSER (chat.js) und stoppt die + Aufnahme von selbst. Hier zaehlen Bytes, weil nur die hier messbar + sind -- eine Dauer waere eine Behauptung des Absenders. */ +const TON_MAX = 4 * 1024 * 1024; + const jetzt = () => new Date().toISOString(); /* ---------- Schranke ---------------------------------------------------- */ @@ -549,9 +567,84 @@ function dateiErkennen(b) { return { art: "pdf", typ: "application/pdf", endung: ".pdf", breite: 0, hoehe: 0 }; } + /* ===================================================================== + SPRACHNACHRICHTEN (23.09.2026) + + Filipe: „dan will ich auch dass man im chat auch sprachnachrichten + und gifts reinschicken kann. also nur die modis rechte linke hand + und dogfather." + + DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN -- das ist keine + Grosszuegigkeit, sondern die Bedingung dafuer, dass es ueberhaupt + auf jedem Handy geht: + WebM/Opus Chrome, Edge, Android + MP4/AAC Safari, iPhone, iPad + Ogg/Opus Firefox + Wer nur einen davon nimmt, baut eine Funktion, die bei der Haelfte + des Teams stumm bleibt. + + WARUM KEIN MP3: Kein Aufnahmegeraet im Browser erzeugt MP3. Es + zuzulassen hiesse, eine Tuer fuer Musikdateien aufzumachen, nach + der niemand gefragt hat. Bestellt waren Sprachnachrichten. + + DER BEHAELTER ALLEIN GENUEGT NICHT. WebM und MP4 tragen genauso + gut Video -- und ein Video, das hier als "Ton" durchginge, landete + in einem Abspielgeraet ohne Bild: Es liefe, man hoerte etwas, und + niemand wuesste, dass die Haelfte fehlt. Deshalb wird im Kopf nach + der KENNUNG DER SPUR gesucht: Steht dort eine Videospur, wird + abgelehnt; steht keine Tonspur darin, ebenfalls. + + GESUCHT WIRD NUR IM ANFANG (16 kB). Dort stehen bei allen drei + Formaten die Spurangaben. Die ganze Datei zu durchsuchen hiesse, + bei vier Megabyte nach Zeichenketten zu graben -- und dabei + irgendwann ein "avc1" mitten in den Tondaten zu finden. + ===================================================================== */ + const anfang = b.toString("latin1", 0, Math.min(b.length, 16384)); + const hatVideo = /V_VP8|V_VP9|V_AV1|V_MPEG|avc1|hvc1|hev1|av01|mp4v|theora/.test(anfang); + + /* --- WebM / Matroska: EBML-Kennung ------------------------------- */ + if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) { + if (hatVideo) return null; + if (!/A_OPUS|A_VORBIS|A_AAC|A_PCM/.test(anfang)) return null; + return { art: "ton", typ: "audio/webm", endung: ".webm", breite: 0, hoehe: 0 }; + } + + /* --- Ogg ---------------------------------------------------------- */ + if (kopf(0, 4) === "OggS") { + if (hatVideo) return null; + if (!/OpusHead|vorbis|Speex|FLAC/.test(anfang)) return null; + return { art: "ton", typ: "audio/ogg", endung: ".ogg", breite: 0, hoehe: 0 }; + } + + /* --- MP4 / M4A: "ftyp" steht an Stelle 4, nicht 0 ----------------- */ + if (kopf(4, 8) === "ftyp") { + if (hatVideo) return null; + if (!/M4A |mp4a|Opus/.test(anfang)) return null; + return { art: "ton", typ: "audio/mp4", endung: ".m4a", breite: 0, hoehe: 0 }; + } + return null; } +/** Wie heisst ein Anhang in einem Satz? + * + * „Foto" steht in der Gespraechsliste, im Zitat, an einer angehefteten + * Nachricht und in der Benachrichtigung -- an vier Stellen stand + * dieselbe Aufzaehlung, jede mit ihrem eigenen `? :`. Beim Hinzukommen + * der Sprachnachricht waeren es vier Aenderungen gewesen, und die + * vierte ist die, die man vergisst. Jetzt eine Stelle. + * + * LEER FUER UNBEKANNTES, nicht "Anhang": Der Aufrufer setzt das Wort + * hinter ein `||` -- ein Ersatzwort fuer etwas, das es nicht gibt, + * stuende sonst in einer Vorschau, in der nichts ist. + */ +export function anhangWort(art) { + return art === "bild" ? "Foto" + : art === "pdf" ? "PDF" + : art === "ton" ? "Sprachnachricht" + : ""; +} + /** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte * bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit * "../" darin nirgendwo hinkommt. */ @@ -963,12 +1056,13 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { bild: t.bild })), letzte: letzte ? { /* Ein Anhang ohne Begleittext hätte hier eine leere Zeile - hinterlassen. "Foto"/"PDF" ist die Vorschau, die man in - jeder Gesprächsliste erwartet. */ + hinterlassen. „Foto", „PDF", „Sprachnachricht" ist die + Vorschau, die man in jeder Gesprächsliste erwartet -- das + Wort kommt aus anhangWort(), an einer Stelle für alle vier + Orte, an denen es gebraucht wird. */ text: letzte.weg_am ? null : (letzte.text ? letzte.text.slice(0, 140) - : (letzte.anhang_art === "bild" ? "Foto" - : letzte.anhang_art === "pdf" ? "PDF" : "")), + : anhangWort(letzte.anhang_art)), zurueckgenommen: !!letzte.weg_am, von: letzte.von, erstellt: letzte.erstellt, } : null, @@ -1299,7 +1393,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { p.bild AS von_bild, p.chat_kachel AS von_kachel, n.antwort_auf, n.angeheftet_am, n.rudel, n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse, - n.anhang_breite, n.anhang_hoehe, + n.anhang_breite, n.anhang_hoehe, n.anhang_dauer, 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 @@ -1394,13 +1488,25 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { darf_rudel: istTeamDogi(req.person) && leute.some((t) => t.id !== req.person.id && istTeamDogi(t)), rudel_wort: RUDEL_WORT, + /* DARF ICH SPRACHNACHRICHTEN SCHICKEN? (23.09.2026) + + Aus derselben Funktion wie die Schranke in der Anhang-Route. + Der Browser vergleicht keine Rollen -- assets/js/chat.js + bekommt jeder, der die Seite oeffnet. + + ANDERS ALS BEIM RUDEL HAENGT ES NICHT AM RAUM: Eine + Sprachnachricht an einen Creator ist eine Sprachnachricht an + einen Creator; sie erreicht ihn. Der Ruf ans Rudel erreicht in + so einem Raum niemanden -- deshalb dort die zweite Bedingung + und hier nicht. */ + darf_ton: istTeamDogi(req.person), angeheftet: angeheftet.map((a) => ({ id: a.id, von: a.von || "Gelöscht", heftete: a.heftete || null, seit: a.angeheftet_am, text: String(a.text || "").slice(0, 200) - || (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""), + || anhangWort(a.anhang_art), })), /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ /* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht @@ -1445,7 +1551,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { von: n.zitat_von || "Gelöscht", text: n.zitat_weg ? null : (String(n.zitat_text || "").slice(0, 140) - || (n.zitat_anhang === "bild" ? "Foto" : n.zitat_anhang === "pdf" ? "PDF" : "")), + || anhangWort(n.zitat_anhang)), zurueckgenommen: !!n.zitat_weg, } : null, /* Der Anhang wird als fertige ADRESSE geliefert, nicht als @@ -1455,6 +1561,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { 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, + dauer: n.anhang_dauer || 0, weg: `/workspace/api/chat/anhang/${n.id}`, } : null, })); @@ -1722,10 +1829,45 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang", 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.", + fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF), PDF und Sprachnachrichten.", }); } + /* ==== SPRACHNACHRICHTEN SIND NICHT FUER ALLE (23.09.2026) ===== + + Filipe: „dan will ich auch dass man im chat auch + sprachnachrichten und gifts reinschicken kann. also nur die + modis rechte linke hand und dogfather." + + Das „also nur" ist der Punkt. Fotos und PDF darf jeder + schicken, auch Creator -- das steht als Entscheidung im + Kommentar ueber dieser Route und bleibt. Ton nicht. + + HIER UND NICHT NUR IM BROWSER. Ein ausgeblendeter Knopf ist + eine Bitte; abgelehnt wird erst an dieser Stelle. Dieselbe + Ueberlegung wie bei der Nachtruhe zwanzig Zeilen darueber. */ + if (erkannt.art === "ton" && !istTeamDogi(req.person)) { + return res.status(403).json({ + fehler: "Sprachnachrichten schicken nur Team Dogi und DogFather.", + }); + } + if (erkannt.art === "ton" && req.body.length > TON_MAX) { + return res.status(413).json({ + fehler: `Die Sprachnachricht ist zu lang – erlaubt sind ${Math.round(TON_MAX / 1048576)} MB.`, + }); + } + + /* WIE LANG SIE IST, SAGT DER ABSENDER -- und das ist eine + Behauptung, keine Messung. Sie steht nur in der Vorschau, bis + das Abspielgeraet die wahre Laenge kennt. Begrenzt wird sie + trotzdem: Ohne Obergrenze stuende in der Gespraechsliste + „Sprachnachricht (999:59)", und die Liste saehe kaputt aus. */ + let dauer = null; + if (erkannt.art === "ton") { + const roh = Number.parseInt(req.get("x-dauer") || "", 10); + dauer = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 60 * 60 * 1000) : null; + } + let begleittext = ""; try { begleittext = decodeURIComponent(req.get("x-text") || ""); } catch { begleittext = ""; } begleittext = begleittext.trim().slice(0, TEXT_MAX); @@ -1744,11 +1886,11 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang", 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 (?,?,?,?,?,?,?,?,?,?,?)`) + anhang_groesse, anhang_breite, anhang_hoehe, anhang_dauer) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) .run(raumId, req.person.id, begleittext, n, aufPlatte, name, erkannt.art, erkannt.typ, - req.body.length, erkannt.breite || null, erkannt.hoehe || null); + req.body.length, erkannt.breite || null, erkannt.hoehe || null, dauer); 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 = ?") @@ -1762,8 +1904,10 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang", art: erkannt.art, typ: erkannt.typ, name, groesse: req.body.length, breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0, + dauer: dauer || 0, weg: `/workspace/api/chat/anhang/${id}`, }, + rudel: false, }; const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id); chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id)); @@ -2720,8 +2864,7 @@ export function pushTextFuer(art, nachricht, raumId) { Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum Öffnen, um zu erfahren, ob es wichtig war. */ text: String(nachricht.text || "").slice(0, 120) - || (nachricht.anhang?.art === "bild" ? "Foto" - : nachricht.anhang?.art === "pdf" ? "PDF" : ""), + || anhangWort(nachricht.anhang?.art), ziel, }; } diff --git a/server/workspace.js b/server/workspace.js index 10745f83..d7e46f7f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -3267,6 +3267,26 @@ function umstellungen(d) { ["chat_nachrichten", "anhang_breite", "INTEGER"], ["chat_nachrichten", "anhang_hoehe", "INTEGER"], + /* WIE LANG IST DIE SPRACHNACHRICHT? (23.09.2026) + + Filipe: „dan will ich auch dass man im chat auch sprachnachrichten + und gifts reinschicken kann." + + In Millisekunden, und sie ist AUSDRUECKLICH nur fuer die Anzeige, + bevor die Datei geladen ist: In der Gespraechsliste steht + „Sprachnachricht (0:14)", und in der Blase steht die Laenge schon + da, waehrend der Ton noch laedt. Ohne sie springt die Zeile in + dem Moment, in dem die Zahl nachkommt. + + SIE KOMMT VOM ABSENDER und ist damit eine Behauptung, keine + Messung -- das gehoert gesagt. Sie zu ueberpruefen hiesse, vier + Behaelterformate zu zerlegen (WebM schreibt die Dauer beim + Aufnehmen oft gar nicht erst hinein). Der Wert wird deshalb + begrenzt, und die WAHRE Laenge sagt ohnehin das Abspielgeraet, + sobald es die Datei kennt. Was wirklich schuetzt, ist die + Byte-Grenze, nicht diese Zahl. */ + ["chat_nachrichten", "anhang_dauer", "INTEGER"], + /* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026). Filipe: "ich will nur dass du dich informierst und schaust was diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index f779ed3e..a25cc1c5 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +