package handlers import ( "database/sql" "net/http" "time" "github.com/andrew/go-computer/internal/db" ) // Page revision history (trust phase). Every committed content version gets // an immutable snapshot; restoring an old snapshot writes a new version rather // than rewriting history. Snapshots are owner-scoped through their page. func capturePageRevision(r *http.Request, tx *sql.Tx, pageID string) error { _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageRevision"(id,"pageId",version,title,content,icon) SELECT $1,p.id,p.version,p.title,p.content,p.icon FROM "Page" p WHERE p.id=$2 ON CONFLICT ("pageId",version) DO NOTHING`, db.NewID(), pageID) return e } func revisionOwned(r *http.Request, q db.Queryer, pageID, revisionID string) (object, error) { if _, e := owned(r, q, "Page", pageID, "ownerId"); e != nil { return nil, e } return db.One(r.Context(), q, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID) } // HandlePageRevisionsList returns metadata only — the editor fetches a body on // demand (a page can have a long history and each body is up to 512 KiB). func HandlePageRevisionsList(w http.ResponseWriter, r *http.Request) { pageID := r.PathValue("id") if _, e := owned(r, database(r), "Page", pageID, "ownerId"); e != nil { respond(w, 0, nil, e) return } v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object( 'id',r.id,'version',r.version,'title',r.title,'icon',r.icon, 'createdAt',r."createdAt",'length',length(r.content)) FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version DESC`, pageID) respond(w, 200, v, e) } func HandlePageRevisionsGet(w http.ResponseWriter, r *http.Request) { v, e := revisionOwned(r, database(r), r.PathValue("id"), r.PathValue("revisionId")) respond(w, 200, v, e) } // HandlePageRevisionsRestore copies a snapshot into the live page as a new // version. It requires the current base version (the same DATA-05 rule as a // content save), so restoring while another tab writes is a 409, not a silent // overwrite. The restored state itself becomes a history entry. func HandlePageRevisionsRestore(w http.ResponseWriter, r *http.Request) { pageID, revisionID := r.PathValue("id"), r.PathValue("revisionId") b, ok := read(w, r) if !ok { return } baseVersion, e := patchVersion(b) if e != nil { respond(w, 0, nil, e) return } var v object e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { if e := lockUser(r, tx); e != nil { return e } current, e := owned(r, tx, "Page", pageID, "ownerId") if e != nil { return e } if stored, _ := current["version"].(float64); int(stored) != baseVersion { return conflict("Page was modified elsewhere; reload to see the latest version") } rev, e := db.One(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID) if e != nil { return e } if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET title=$1,content=$2,icon=$3,version=version+1,"updatedAt"=$4 WHERE id=$5 AND "ownerId"=$6`, str(rev, "title"), str(rev, "content"), rev["icon"], time.Now().UTC(), pageID, user(r)); e != nil { return e } // Restoring content also restores its derived graph. Targets that no // longer exist (or are no longer owned) are ignored by syncPageGraph. if e := syncPageGraph(r.Context(), tx, pageID, user(r), str(rev, "content")); e != nil { return e } if e := capturePageRevision(r, tx, pageID); e != nil { return e } v, e = pageView(r.Context(), tx, pageID, user(r)) return e }) respond(w, 200, v, e) }