Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.
New internal/auth moves it into the request context:
- Middleware(Resolver) resolves the caller once per API request
- handlers read auth.UserID(r.Context()) instead of naming a user
- Resolver is the seam an Authentik session check drops into
- StaticResolver(db.LocalUserID) keeps Petal single-user today
Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.
main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.
Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:
- setStatus (accept/dismiss) updated a suggestion by bare id with no
ownership check at all
- listForDoc/fetchPending read a document's suggestions by doc_id
alone; a suggestion quotes the sentence it corrects, so that leaked
the source prose
Both now scope through documents.user_id.
Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.
Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.
Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
284 lines
9.7 KiB
Go
284 lines
9.7 KiB
Go
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/auth"
|
|
"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")
|
|
userID := auth.UserID(r.Context())
|
|
|
|
doc, err := h.fetch(userID, docID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
versions, err := h.passportVersions(userID, 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(userID, 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, userID,
|
|
)
|
|
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()
|
|
}
|