From 2d14907d0d6559df705e3ba65f68dbf60c46dc7a Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 23 Sep 2026 01:51:56 +0200 Subject: [PATCH] Chat: Sprachnachrichten -- und ein stiller Fehler, der jedes Foto betraf Filipe: "dan will ich auch dass man im chat auch sprachnachrichten und gifts reinschicken kann. also nur die modis rechte linke hand und dogfather." Dieser Commit macht die Sprachnachrichten. Die GIFs kommen als naechstes -- ich lese "gifts" als GIFs und sage das ausdruecklich, statt es stillschweigend anzunehmen. AUFNEHMEN: TIPPEN, NICHT HALTEN ------------------------------- Gedruecktbleiben ist der bekanntere Weg und der schlechtere: Wer beim Sprechen verrutscht, verliert die ganze Aufnahme, und mit einer Hand am Kaffee haelt niemand neunzig Sekunden still. Einmal tippen startet, das Haekchen schickt, das Kreuz verwirft. Drei Minuten Hoechstdauer, danach stoppt sie von selbst -- und schickt NICHT von selbst ab. Wer die Grenze erreicht, hat gerade mitten im Satz aufgehoert; das ungefragt zu verschicken waere die schlechteste Sekunde dafuer. Unter einer Sekunde wird gar nichts geschickt: Das ist ein Versehen, kein Inhalt. Die Aufnahmespur wird beim Beenden UND beim Verlassen der Seite geschlossen. Ein Mikrofon, das offen bleibt, ist ein Vertrauensbruch, auch wenn niemand zuhoert. DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN --------------------------------------------- WebM/Opus (Chrome, Android), MP4/AAC (Safari, iPhone), Ogg/Opus (Firefox). Wer nur einen nimmt, baut eine Funktion, die bei der Haelfte des Teams stumm bleibt -- und zwar ohne Fehlermeldung. DER BEHAELTER ALLEIN GENUEGT NICHT: WebM und MP4 tragen genauso gut Video. Ein Video, das als "Ton" durchginge, landete in einem Abspielgeraet ohne Bild -- es liefe, man hoerte etwas, und niemand wuesste, dass die Haelfte fehlt. Die Erkennung sieht deshalb auf die KENNUNG DER SPUR: Videospur -> abgelehnt, keine Tonspur -> abgelehnt. Kein MP3: Kein Aufnahmegeraet im Browser erzeugt MP3. Es zuzulassen hiesse, eine Tuer fuer Musikdateien aufzumachen, nach der niemand gefragt hat. "ALSO NUR" IST DER PUNKT ------------------------ Fotos und PDF darf jeder schicken, auch Creator -- das war eine Entscheidung vom 09.09. und bleibt. Ton nicht. Geprueft wird in der Route (istTeamDogi), nicht nur im Browser: Ein ausgeblendeter Knopf ist eine Bitte, abgelehnt wird erst am Server. Eigene Byte-Grenze fuer Ton (4 MB statt 12). Zwoelf Megabyte sind fuer ein Foto richtig und fuer eine Sprachnachricht sinnlos -- das waeren rund fuenfzig Minuten am Stueck. Die Laenge kommt vom Absender und ist damit eine BEHAUPTUNG, keine Messung; das steht so an der Spalte. Sie dient nur der Vorschau, bis das Abspielgeraet die wahre Laenge kennt. Was schuetzt, sind die Bytes. "FOTO"/"PDF" STAND AN VIER STELLEN ---------------------------------- Dieselbe Aufzaehlung in Gespraechsliste, Zitat, angehefteter Nachricht und Benachrichtigung -- jede mit ihrem eigenen Fragezeichen-Doppelpunkt. Mit der Sprachnachricht waeren es vier Aenderungen gewesen, und die vierte ist die, die man vergisst. Jetzt anhangWort() an einer Stelle. DER FUND: JEDES FOTO MELDETE "KEINE VERBINDUNG" ----------------------------------------------- Beim Anfassen derselben Funktion aufgefallen: In `anhangSchicken` stand seit dem 19.09. `const { nachricht } = e.daten;` -- ein `e`, das es dort nicht gibt (gemeint war `ergebnis`). Die Zeile wirft, der `catch` daneben faengt, und der Absender liest: "Keine Verbindung -- der Anhang wurde nicht geschickt." Das Foto war aber da. Es kam Sekunden spaeter ueber den Ereignisstrom in den Verlauf, mit einer roten Meldung darueber, die das Gegenteil behauptet -- und der getippte Begleitsatz blieb im Feld stehen, also schickt man es noch einmal. WARUM DAS VIER TAGE UNBEMERKT BLIEB, und das ist der lehrreiche Teil: pruef-chat-anhaenge war die ganze Zeit gruen. Sie schickt mit `fetch` an die Route -- der bequeme Weg, und er prueft den Server gruendlich. Sie prueft aber nicht den Weg, den ein Mensch geht. Der Fehler lag hinter der Bueroklammer, und dort hat nie jemand hingesehen. Dazu fuehlt er sich wie ein Netzproblem an -- und Netzprobleme sucht niemand im Programm. Jetzt geht ein Block durch das Dateifeld selbst und sieht auf das, was der Mensch danach sieht: steht dort eine Meldung, und ist das Feld leer? Gegenprobe gefahren -- mit dem alten Fehler wieder eingesetzt werden genau diese zwei Zeilen rot, mit dem Wortlaut von oben. Uebernommen wird eine frisch geschickte Nachricht jetzt an EINER Stelle (nachrichtUebernehmen), fuer Anhang und Sprachnachricht. Zwei Fassungen waeren zwei Gelegenheiten fuer denselben Fehler. AUGENSCHONEND ------------- Kein Blinkpunkt waehrend der Aufnahme -- der ist genau das, was die Hausregel ausschliesst. Der Punkt atmet langsam (1,8 s, 45 bis 100 %) und steht bei prefers-reduced-motion ganz still; die laufende Zeit daneben traegt die Auskunft ohnehin. Die Farben der Sprachnachricht-Blase kommen aus `--blase-leise`, der fuer DIESE Blase ausgerechneten leisen Schrift. Eine feste Farbe waere dieselbe Wette, die am 22.09. beim Loeschknopf 1,91:1 auf Babyblau ergeben hat. GEPRUEFT -------- pruef-chat-anhaenge: 86 Pruefungen, 0 Fehler (vorher 60). Aufgenommen wird mit MediaRecorder im echten Browser ueber den echten Knopf, mit Chromiums erfundenem Mikrofon. Damit prueft der Abschnitt nebenbei das, was keine gebastelte Datei pruefen koennte: ob die Erkennung im Server versteht, was ein Browser wirklich erzeugt (gemessen: 35 829 Bytes audio/webm, 2743 ms). * DogFather sieht den Mikrofonknopf, der Scout nicht * der Scout schickt dieselbe Aufnahme an der Oberflaeche vorbei: 403, und nichts davon ist gespeichert * ein Video im Ton-Behaelter: 415 * Laenge, Vorlesewort, Bedienbarkeit, preload=metadata, Breite * "Sprachnachricht" steht in der Gespraechsliste statt einer leeren Zeile pruef-chat, pruef-chat-optik: unveraendert gruen. Co-Authored-By: Claude Opus 5 --- server/pruef-chat-anhaenge.mjs | 246 +++++++++++++++++++++- server/workspace-chat.js | 169 +++++++++++++-- server/workspace.js | 20 ++ workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 135 ++++++++++++ workspace/assets/js/chat.js | 361 ++++++++++++++++++++++++++++++-- workspace/aufgaben.html | 36 ++-- workspace/automation.html | 32 +-- workspace/befinden.html | 32 +-- workspace/bereich.html | 44 ++-- workspace/bewerben.html | 36 ++-- workspace/bewerbungen.html | 34 +-- workspace/calls.html | 34 +-- workspace/chat.html | 83 ++++++-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 32 +-- workspace/hilfe.html | 28 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/material.html | 32 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 ++-- workspace/rechte.html | 32 +-- workspace/report.html | 36 ++-- workspace/scouting.html | 32 +-- workspace/start.html | 32 +-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 ++-- workspace/talente.html | 34 +-- workspace/team.html | 32 +-- workspace/teamlage.html | 32 +-- workspace/teilen.html | 28 +-- workspace/treff-moderation.html | 30 +-- workspace/treff-regeln.html | 30 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 28 +-- workspace/werdegang.html | 30 +-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 +-- 44 files changed, 1532 insertions(+), 614 deletions(-) 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`. --> - - + + - +