package docs // Writing passport: a standalone, printable report showing *how* a document was // written — when each snapshot landed, how the word count grew, how the work // broke into sessions. It exists because automated "AI detector" verdicts are // unreliable and skew against non-native English writers, so the useful thing to // hand someone who doubts your authorship is not a score but a record. // // The report is deliberately modest about what it proves (see passportLimits): // it evidences a plausible writing process, it does not certify one. import ( "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "net/http" "time" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" ) // Passport tuning. const ( // sessionGap is the idle time that separates one writing session from the // next. Auto-snapshots fire at most every 3 minutes while typing, so any // gap far above that means the writer stepped away. 45 minutes keeps a // coffee break inside one session but splits morning from evening work. sessionGap = 45 * time.Minute // jumpNoteThreshold is the share of the final word count a single // snapshot-to-snapshot increase must exceed before the report calls it out. // A large jump is the first thing a skeptical reader will ask about, so the // report raises it rather than leaving it to be discovered. jumpNoteThreshold = 0.25 ) // chainHash links a snapshot to its predecessor. Covering prev_hash makes each // hash depend on the entire history before it, so altering any earlier snapshot // invalidates every later one; covering created_at means a row cannot be // silently backdated. // // This detects tampering with the local database. It is not third-party // attestation — someone with the database and this function could regenerate a // consistent chain from scratch. func chainHash(prevHash, docID string, createdAt time.Time, wordCount int, text string) string { h := sha256.New() fmt.Fprintf(h, "%s\x00%s\x00%d\x00%d\x00%s", prevHash, docID, createdAt.UTC().UnixNano(), wordCount, text) return hex.EncodeToString(h.Sum(nil)) } // --- report model ----------------------------------------------------------- // passportSession is one continuous stretch of work — snapshots with no // sessionGap-sized pause between them. type passportSession struct { Start, End time.Time Snapshots int WordsAdded int // net change across the session; negative when trimming } // Duration is the observed length of the session: first snapshot to last. A // single-snapshot session reports zero, which is why total active time is // described as a lower bound. func (s passportSession) Duration() time.Duration { return s.End.Sub(s.Start) } // chain verification outcomes, in the order the report prefers to report them. const ( chainVerified = "verified" // every hash recomputes and every link holds chainGaps = "gaps" // hashes valid, links broken — consistent with pruning chainPartial = "partial" // some snapshots predate the hash chain chainUnverifiable = "unverifiable" // no snapshot carries a hash chainBroken = "broken" // a hash does not match its own contents ) // passportData is everything the template renders. type passportData struct { Doc db.Document Versions []db.DocumentVersion // ascending by time Sessions []passportSession FirstAt time.Time LastAt time.Time Span time.Duration // wall-clock first snapshot → last ActiveTime time.Duration // summed session durations; a lower bound LargestJump int // biggest single snapshot-to-snapshot word increase LargestJumpAt time.Time LargestJumpIdx int // index into Versions, so the chart can mark it NoteJump bool // jump is large enough to be worth pre-empting ChainStatus string UnhashedCount int GeneratedAt time.Time } // buildPassport derives the report from a document and its snapshots, which must // be ordered oldest-first. It assumes nothing about snapshot spacing. func buildPassport(doc db.Document, versions []db.DocumentVersion) passportData { d := passportData{ Doc: doc, Versions: versions, GeneratedAt: time.Now(), } if len(versions) == 0 { d.ChainStatus = chainUnverifiable return d } d.FirstAt = versions[0].CreatedAt d.LastAt = versions[len(versions)-1].CreatedAt d.Span = d.LastAt.Sub(d.FirstAt) cur := passportSession{Start: versions[0].CreatedAt, End: versions[0].CreatedAt, Snapshots: 1} // Baseline for the running session's net-words figure. Later sessions // measure from the *previous* session's final count, not from their own // first snapshot, because that first snapshot already contains the few // minutes of typing that preceded it — measuring from it would drop that // work. The first session is the exception: it measures from its own first // snapshot rather than from zero, so a history whose early snapshots were // pruned understates session one instead of reporting the words it never // saw as a sudden addition. startWords := versions[0].WordCount for i := 1; i < len(versions); i++ { v, prev := versions[i], versions[i-1] if delta := v.WordCount - prev.WordCount; delta > d.LargestJump { d.LargestJump, d.LargestJumpAt, d.LargestJumpIdx = delta, v.CreatedAt, i } if v.CreatedAt.Sub(prev.CreatedAt) > sessionGap { cur.WordsAdded = prev.WordCount - startWords d.Sessions = append(d.Sessions, cur) cur = passportSession{Start: v.CreatedAt, End: v.CreatedAt, Snapshots: 1} startWords = prev.WordCount continue } cur.End = v.CreatedAt cur.Snapshots++ } cur.WordsAdded = versions[len(versions)-1].WordCount - startWords d.Sessions = append(d.Sessions, cur) for _, s := range d.Sessions { d.ActiveTime += s.Duration() } final := versions[len(versions)-1].WordCount d.NoteJump = final > 0 && float64(d.LargestJump)/float64(final) > jumpNoteThreshold d.ChainStatus, d.UnhashedCount = verifyChain(doc, versions) return d } // verifyChain recomputes every snapshot's hash and checks that each links to the // one before it. Returns the outcome and how many snapshots predate the chain. // // Broken *links* are not evidence of tampering on their own: auto-snapshot // pruning legitimately removes rows from the middle of the history, which severs // the links across the hole. So a link break is reported as a gap unless the // document is in preserve-history mode, where nothing should ever be removed. A // hash that fails to match its *own* contents is unambiguous, and always broken. func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string, unhashed int) { var ( hashed int linkBreak bool prevHash string havePrev bool ) for _, v := range versions { if v.ContentHash == "" { unhashed++ havePrev = false // can't vouch for what follows an unhashed row continue } hashed++ want := chainHash(v.PrevHash, v.DocID, v.CreatedAt, v.WordCount, v.ContentText) if want != v.ContentHash { return chainBroken, unhashed } if havePrev && v.PrevHash != prevHash { linkBreak = true } prevHash, havePrev = v.ContentHash, true } switch { case hashed == 0: return chainUnverifiable, unhashed case linkBreak && doc.PreserveHistory: // Nothing should have been removed from a preserved history. return chainBroken, unhashed case linkBreak: return chainGaps, unhashed case unhashed > 0: return chainPartial, unhashed default: return chainVerified, unhashed } } // --- HTTP ------------------------------------------------------------------- // passport renders the report for one document as a standalone HTML download. // HTML rather than PDF for the same reason as the other exports: a CJK-safe PDF // needs an embedded Unicode font or a headless browser. The page is styled for // printing, so "Save as PDF" in the browser produces the handoff artifact. func (h *Handler) passport(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") doc, err := h.fetch(docID) if errors.Is(err, sql.ErrNoRows) { notFound(w) return } if err != nil { httputil.ServerError(w, err) return } versions, err := h.passportVersions(docID) if err != nil { httputil.ServerError(w, err) return } body := renderPassport(buildPassport(doc, versions)) filename := sanitizeFilename(doc.Title) if filename == "" { filename = "untitled" } filename += " - writing passport.html" w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename))) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) _, _ = w.Write(body) } // passportVersions loads every snapshot oldest-first with the fields the report // and the chain check need — including content_text, which the list endpoint // omits as too heavy but verification cannot do without. func (h *Handler) passportVersions(docID string) ([]db.DocumentVersion, error) { rows, err := h.DB.Query( `SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind, v.created_at, v.content_hash, v.prev_hash FROM document_versions v JOIN documents d ON d.id = v.doc_id WHERE v.doc_id = ? AND d.user_id = ? ORDER BY v.created_at ASC, v.rowid ASC`, docID, db.LocalUserID, ) if err != nil { return nil, err } defer rows.Close() var out []db.DocumentVersion for rows.Next() { var v db.DocumentVersion if err := rows.Scan( &v.ID, &v.DocID, &v.Title, &v.ContentText, &v.WordCount, &v.Kind, &v.CreatedAt, &v.ContentHash, &v.PrevHash, ); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() }