package files import ( "context" "crypto/sha256" "encoding/hex" "io" "net/http" "github.com/rs/zerolog/log" ) // Ausliefern mit Prüfung der SHA-256-Summe. // // Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche** // Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine // Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und // merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht. // // In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen, // **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen. // Deshalb liegt sie jetzt hier und wird von beiden gerufen. // verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten. // // Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers // längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB // kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt. const verifyTail = 32 * 1024 // VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die // Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht". type VerifyInfo struct { FileID string StorageKey string Size int64 } // ServeVerified kopiert `src` nach `w` und prüft dabei die Summe. // // Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die // Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der // Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger // Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser // ein abgebrochener Download als eine heile Datei mit falschem Inhalt. func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) { h256 := sha256.New() zurueck := &tailWriter{w: w, keep: verifyTail} if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil { return // Gegenstelle ist weg } gelesen := hex.EncodeToString(h256.Sum(nil)) if gelesen != erwartet { log.Ctx(ctx).Error(). Str("file_id", info.FileID). Str("storage_key", info.StorageKey). Str("erwartet", erwartet). Str("gelesen", gelesen). Int64("size", info.Size). Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt") // ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin // fallen zu lassen: net/http schließt die Verbindung und schreibt // **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine // Panik ausdrücklich durch). panic(http.ErrAbortHandler) } // Stimmt sie, darf der Rest hinterher. _ = zurueck.Flush() } // tailWriter reicht alles durch außer den letzten `keep` Bytes. type tailWriter struct { w io.Writer keep int buf []byte } func (t *tailWriter) Write(p []byte) (int, error) { t.buf = append(t.buf, p...) if ueber := len(t.buf) - t.keep; ueber > 0 { if _, err := t.w.Write(t.buf[:ueber]); err != nil { return 0, err } t.buf = append(t.buf[:0], t.buf[ueber:]...) } return len(p), nil } // Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen, // bleibt die Antwort unvollständig – genau das ist der Zweck. func (t *tailWriter) Flush() error { if len(t.buf) == 0 { return nil } _, err := t.w.Write(t.buf) t.buf = t.buf[:0] return err }