oficina / sheets Public

feat: create and list sheets, no script required

New takes a Config struct now. It went from two positional arguments to
five in one plan, and the fifth is a string beside another string —
the shape a call site gets silently wrong.

Origin-checking CSRF is mounted app-wide rather than per route, because
forgetting one route fails silently and there is no token to mint.
Router skips it when Origin is empty, which only a test App ever is:
Protect("") would compare every Origin header against the empty string
and refuse every POST, turning a missing fixture into a wall of 403s
that look like an auth bug.

requireSession is auth.RequireSession in production and a pass-through
with no auth, so the routes below stay testable without a mailer and a
full magic-link round trip. main exits before New if sign-in is not
configured, so production cannot reach the pass-through.

POST answers 303: a POST that renders is a POST that resubmits on
reload, and the second submission makes a second sheet. A missing sheet
404s rather than 403s — a 403 confirms the row exists, which is a fact
about somebody else's data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev 58ee7ddedc0d15f4773006207763916554cb4975 parent e239556
14 files changed, +530 −37
  • cmd/sheets/main.go +7 −1
  • internal/sheets/app.go +92 −21
  • internal/sheets/app_test.go +1 −1
  • internal/sheets/begin_test.go +13 −1
  • internal/sheets/handlers_sheets.go +119 −0
  • internal/sheets/handlers_sheets_test.go +176 −0
  • internal/sheets/helpers_test.go +14 −2
  • internal/sheets/page_test.go +1 −1
  • internal/sheets/render_test.go +3 −3
  • internal/sheets/sheetsvc.go +18 −0
  • internal/sheets/signin_test.go +4 −4
  • internal/sheets/templates/sheet.html +42 −0
  • internal/sheets/templates/sheets.html +37 −0
  • internal/sheets/theme_test.go +3 −3
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
index 095fb87..29819d6 100644
--- a/cmd/sheets/main.go
+++ b/cmd/sheets/main.go
@@ -91,7 +91,13 @@ func main() {
os.Exit(1)
}
- app := sheets.New(d.G, signin, sealer, sheets.Admits(authEnv.Owners))
+ app := sheets.New(sheets.Config{
+ DB: d.G,
+ Auth: signin,
+ Sealer: sealer,
+ Origin: authEnv.Origin,
+ Admit: sheets.Admits(authEnv.Owners),
+ })
// rastrillo.Options.Mux wants a *http.ServeMux; the app's own
// router is a chi.Router (an http.Handler), so it mounts under one.
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 13f2e5d..4ea351a 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -6,40 +6,89 @@ import (
"net/http"
"github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/csrf"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
+ "amadan.net/oficina/sheets/internal/sheets/live"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
+// Config is everything an App needs.
+//
+// A struct rather than positional parameters: this grew from two to
+// five arguments in one plan, and the fifth — Origin — is a string
+// sitting next to another string, which is the shape a call site gets
+// silently wrong.
+type Config struct {
+ // DB is the app's GORM handle. Nil only in tests that render pages
+ // without touching storage.
+ DB *gorm.DB
+
+ // Auth is the sign-in flow. Nil ONLY in tests: Router then serves
+ // the sign-in page but mounts none of auth's handlers, so a
+ // nil-auth App can render pages without a database or a mailer.
+ // Production always passes one — main refuses to boot without the
+ // configuration auth needs.
+ Auth *auth.Auth
+
+ // Sealer seals every cell, title and tab name. Nil only in tests
+ // that never reach a sheet.
+ Sealer *seal.Sealer
+
+ // Origin is this instance's external origin, scheme included. It is
+ // the CSRF same-origin check's reference value.
+ Origin string
+
+ // Admit is the allowlist, consulted before a link is SENT.
+ // auth.Config.Authorize covers the same list at redemption; this
+ // one exists so we do not email strangers. See handleBegin.
+ Admit func(string) bool
+}
+
// App holds the wiring. One per process.
type App struct {
db *gorm.DB
auth *auth.Auth
sealer *seal.Sealer
+ origin string
+ admit func(string) bool
- // admit is the allowlist, consulted before a link is SENT.
- // auth.Config.Authorize covers the same list at redemption; this
- // one exists so we do not email strangers. See handleBegin.
- admit func(string) bool
+ // hub fans every committed edit out to every other open tab. One per
+ // process, because it holds the live subscriptions: a hub per
+ // request would fan out to nobody, silently.
+ hub *live.Hub
}
-// New builds an App. db and sealer may be nil in tests that do not
-// touch storage; every route that reads or writes a cell checks and
-// answers an error rather than panicking.
-//
-// a may be nil ONLY in tests: Router then serves the sign-in page but
-// mounts none of auth's handlers, so a nil-auth App can render pages
-// without a database or a mailer. Production always passes one — main
-// refuses to boot without the configuration auth needs, so there is no
-// path there that reaches this with nil.
-func New(db *gorm.DB, a *auth.Auth, sealer *seal.Sealer, admit func(string) bool) *App {
- return &App{db: db, auth: a, sealer: sealer, admit: admit}
+// New builds an App.
+func New(cfg Config) *App {
+ return &App{
+ db: cfg.DB,
+ auth: cfg.Auth,
+ sealer: cfg.Sealer,
+ origin: cfg.Origin,
+ admit: cfg.Admit,
+ hub: live.NewHub(),
+ }
}
// Router returns the mounted handler.
func (a *App) Router() http.Handler {
r := chi.NewRouter()
+
+ // Origin-checking CSRF, app-wide, on every state-changing method.
+ // App-wide rather than per-route because the failure mode of
+ // forgetting one route is silent, and there is no token to mint or
+ // remember to check.
+ //
+ // Skipped entirely when Origin is empty, which only ever happens in
+ // a test App: a Protect("") would compare every Origin header
+ // against the empty string and refuse every POST, turning a missing
+ // test fixture into a wall of 403s that look like an auth bug.
+ if a.origin != "" {
+ r.Use(csrf.Protect(a.origin))
+ }
+
// This /healthz is NOT what answers in a deployed instance:
// rastrillo.Serve registers its own GET /healthz on the outer mux
// (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that
@@ -55,16 +104,16 @@ func (a *App) Router() http.Handler {
// should see (TestIndexIsNotACatchAll pins this).
r.Get("/", a.handleIndex)
r.Get("/signin", a.handleSignin)
- // Fingerprinted, immutably cached, and mounted at the FS's own
- // path with no prefix stripped — rastrillo's Assets uses
+ // Fingerprinted and immutably cached, mounted at the FS's own path
+ // with no prefix stripped — rastrillo's Assets uses
// http.FileServerFS semantics, where the URL path is "/" plus the
// FS path.
r.Handle("/static/*", staticHandler())
- // The theme is a per-person preference, set by a form so it works
- // with scripting off (AGENTS.md: light/dark is per person, and a
- // person who needs a high-contrast theme is badly served by being
- // told to enable JavaScript first).
+ // The theme is a per-person preference set by a form, so it works
+ // with scripting off: a person who needs a high-contrast theme is
+ // badly served by being told to enable JavaScript first.
r.Post("/theme", a.handleTheme)
+
if a.auth != nil {
// The four handlers rastrillo/auth expects an app to mount, at
// the paths its own docs use. Verify reads ?token=; the path is
@@ -75,5 +124,27 @@ func (a *App) Router() http.Handler {
r.Get("/auth/verify", a.auth.Verify)
r.Post("/signout", a.auth.Signout)
}
+
+ r.Group(func(r chi.Router) {
+ r.Use(a.requireSession)
+ r.Get("/sheets", a.handleSheetList)
+ r.Post("/sheets", a.handleSheetCreate)
+ r.Get("/sheets/{sheetID}", a.handleSheetPage)
+ })
return r
}
+
+// requireSession is auth.RequireSession in production and a
+// pass-through when there is no auth to require one with.
+//
+// The pass-through is for tests only, and it is deliberate rather than
+// accidental: a nil-auth App has no sessions at all, so requiring one
+// would make every route below untestable without a mailer, an SMTP
+// refusal and a full magic-link round trip. Production cannot reach it
+// — main exits before New if sign-in is not configured.
+func (a *App) requireSession(next http.Handler) http.Handler {
+ if a.auth == nil {
+ return next
+ }
+ return a.auth.RequireSession(next)
+}
diff --git a/internal/sheets/app_test.go b/internal/sheets/app_test.go
index a2b5828..e4eb1a8 100644
--- a/internal/sheets/app_test.go
+++ b/internal/sheets/app_test.go
@@ -12,7 +12,7 @@ import (
// ahead of this one and wins (see the comment on Router in app.go); this
// test only proves the app's router itself is wired and reachable.
func TestHealthz(t *testing.T) {
- app := New(nil, nil, nil, nil)
+ app := New(Config{})
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go
index 6cbb4f1..0efb74f 100644
--- a/internal/sheets/begin_test.go
+++ b/internal/sheets/begin_test.go
@@ -56,7 +56,17 @@ func (m *recorder) addresses() []string {
const testOrigin = "https://sheets.example.com"
+// appWithRecorder builds a signed-in-capable app whose CSRF check is
+// off, because Config.Origin is empty. Most tests want that: they post
+// forms without faking a browser's Sec-Fetch-Site header on every
+// request. appWithOrigin is the protected shape, and
+// TestCSRFRefusesACrossOriginPost is what exercises it.
func appWithRecorder(t *testing.T) (*App, *recorder) {
+ t.Helper()
+ return appWithOrigin(t, "")
+}
+
+func appWithOrigin(t *testing.T, origin string) (*App, *recorder) {
t.Helper()
d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), quietLogger())
if err != nil {
@@ -84,7 +94,9 @@ func appWithRecorder(t *testing.T) (*App, *recorder) {
if err != nil {
t.Fatalf("seal.New: %v", err)
}
- return New(d.G, a, sealer, Admits(owners)), rec
+ return New(Config{
+ DB: d.G, Auth: a, Sealer: sealer, Origin: origin, Admit: Admits(owners),
+ }), rec
}
func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecorder {
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go
new file mode 100644
index 0000000..1bbb6c7
--- /dev/null
+++ b/internal/sheets/handlers_sheets.go
@@ -0,0 +1,119 @@
+package sheets
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+// sheetListView is the sheet list's payload.
+type sheetListView struct {
+ Sheets []SheetSummary
+}
+
+// sheetPageView is the grid page's payload.
+//
+// Rows and Cols are the window's dimensions, sent to the client so the
+// grid builds the right shape before it has fetched a single cell —
+// otherwise the page reflows the moment the first response lands.
+type sheetPageView struct {
+ Sheet SheetSummary
+ Rows int
+ Cols int
+}
+
+// handleSheetList renders every sheet on the instance.
+func (a *App) handleSheetList(w http.ResponseWriter, r *http.Request) {
+ list, err := a.listSheets()
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.render(w, r, "sheets", "Your sheets — Oficina Sheets", http.StatusOK,
+ sheetListView{Sheets: list})
+}
+
+// handleSheetCreate makes a sheet and sends you to it.
+//
+// The title is read field by field with r.FormValue into an explicit
+// struct — never bound onto a GORM model (AGENTS.md / Mechanical
+// rules), which would let a form field set CreatedBy, or DeletedAt, or
+// an id.
+//
+// It answers 303 rather than rendering: a POST that renders is a POST
+// that resubmits on reload, and the second submission makes a second
+// sheet.
+func (a *App) handleSheetCreate(w http.ResponseWriter, r *http.Request) {
+ actor, ok := a.actor(r)
+ if !ok {
+ http.Error(w, "Sign in first.", http.StatusForbidden)
+ return
+ }
+ title := r.FormValue("title")
+ if title == "" {
+ title = "Untitled sheet"
+ }
+ s, err := a.createSheet(title, actor)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10), http.StatusSeeOther)
+}
+
+// handleSheetPage renders the grid.
+func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) {
+ s, ok := a.sheetFromURL(w, r)
+ if !ok {
+ return
+ }
+ a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, sheetPageView{
+ Sheet: s,
+ Rows: defaultWindowRows,
+ Cols: defaultWindowCols,
+ })
+}
+
+// sheetFromURL reads {sheetID} and loads it, answering 404 itself if it
+// is not there. The bool is whether the caller should carry on.
+//
+// A sheet that is not there — or an id that was never a number — is a
+// 404, never a 403 (AGENTS.md). A 403 tells whoever asked that the row
+// exists, which is a fact about somebody else's data.
+func (a *App) sheetFromURL(w http.ResponseWriter, r *http.Request) (SheetSummary, bool) {
+ id, err := strconv.ParseUint(chi.URLParam(r, "sheetID"), 10, 64)
+ if err != nil || id == 0 {
+ a.notFound(w, r)
+ return SheetSummary{}, false
+ }
+ s, err := a.sheetSummary(id)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ a.notFound(w, r)
+ return SheetSummary{}, false
+ }
+ a.fail(w, r, err)
+ return SheetSummary{}, false
+ }
+ return s, true
+}
+
+// notFound answers 404 with nothing that could name a sheet.
+func (a *App) notFound(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "Not found.", http.StatusNotFound)
+}
+
+// fail answers 500.
+//
+// The error is deliberately dropped rather than shown or logged: it can
+// carry a sealed title's plaintext, a cell's contents, or a SQL
+// statement quoting either, and `carlos logs` is platform-held storage
+// we cannot grep or redact (AGENTS.md / Mechanical rules). A code and a
+// coordinate are what an error is allowed to carry here, and this one
+// has neither to give.
+func (a *App) fail(w http.ResponseWriter, _ *http.Request, _ error) {
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+}
diff --git a/internal/sheets/handlers_sheets_test.go b/internal/sheets/handlers_sheets_test.go
new file mode 100644
index 0000000..d0d5ceb
--- /dev/null
+++ b/internal/sheets/handlers_sheets_test.go
@@ -0,0 +1,176 @@
+package sheets
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// postForm builds a form POST. Content-Type matters: without it
+// r.FormValue reads nothing and every field arrives empty, which fails
+// as a validation error rather than as a wiring error.
+func postForm(path string, form url.Values) *http.Request {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return req
+}
+
+func TestPostSheetsCreatesAndRedirects(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Q3 forecast"}}), cookies))
+
+ // 303, not a render: a POST that renders is a POST that resubmits
+ // on reload, and the second submission makes a second sheet.
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /sheets = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/sheets/") || loc == "/sheets/" {
+ t.Fatalf("Location = %q, want the new sheet", loc)
+ }
+
+ page := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies))
+ if page.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", loc, page.Code)
+ }
+ if !strings.Contains(page.Body.String(), "Q3 forecast") {
+ t.Error("the sheet page does not show the title it was just given")
+ }
+}
+
+func TestSheetListShowsEveryTitle(t *testing.T) {
+ app, cookies := signIn(t)
+ for _, title := range []string{"Budget", "Headcount"} {
+ if rec := do(t, app, as(postForm("/sheets", url.Values{"title": {title}}), cookies)); rec.Code != http.StatusSeeOther {
+ t.Fatalf("creating %q = %d", title, rec.Code)
+ }
+ }
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String()
+ for _, want := range []string{"Budget", "Headcount"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the sheet list is missing %q", want)
+ }
+ }
+}
+
+func TestSheetListIsEmptyStateNotAnError(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /sheets with no sheets = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No sheets yet") {
+ t.Error("an instance with no sheets does not offer an empty state")
+ }
+}
+
+// AGENTS.md: a row that isn't yours 404s, never 403s. A 403 tells
+// whoever asked that the row exists, which is a fact about somebody
+// else's data.
+func TestUnknownSheetIs404NotForbidden(t *testing.T) {
+ app, cookies := signIn(t)
+ for _, path := range []string{"/sheets/999999", "/sheets/0", "/sheets/abc"} {
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies))
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rec.Code)
+ }
+ }
+}
+
+// Every sheet route is behind a session. Without this the allowlist
+// admits nobody in particular and the instance is public.
+func TestSheetRoutesRequireASession(t *testing.T) {
+ app, _ := appWithRecorder(t)
+ for _, path := range []string{"/sheets", "/sheets/1"} {
+ rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code == http.StatusOK {
+ t.Errorf("GET %s without a session = 200; it must not serve", path)
+ }
+ }
+ rec := do(t, app, postForm("/sheets", url.Values{"title": {"sneaky"}}))
+ if rec.Code == http.StatusSeeOther {
+ t.Error("POST /sheets without a session created a sheet")
+ }
+}
+
+// A form field must never be bound onto a GORM model (AGENTS.md), which
+// would let a submission set CreatedBy, DeletedAt, or an id.
+func TestSheetCreateIgnoresFieldsItWasNotAsking(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(postForm("/sheets", url.Values{
+ "title": {"Legit"},
+ "id": {"4242"},
+ "created_by": {"9999"},
+ "deleted_at": {"2020-01-01"},
+ }), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /sheets = %d", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc == "/sheets/4242" {
+ t.Fatal("a form field set the sheet's id")
+ }
+ list, err := app.listSheets()
+ if err != nil {
+ t.Fatalf("listSheets: %v", err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("expected exactly one sheet, got %d — a deleted_at field may have landed", len(list))
+ }
+}
+
+func TestSheetCreateWithNoTitleStillMakesASheet(t *testing.T) {
+ // An empty title is a slip, not an attack. Refusing it would lose
+ // whatever the person was about to type into the grid.
+ app, cookies := signIn(t)
+ rec := do(t, app, as(postForm("/sheets", url.Values{"title": {""}}), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /sheets with no title = %d, want 303", rec.Code)
+ }
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String()
+ if !strings.Contains(body, "Untitled sheet") {
+ t.Error("a titleless sheet is not named in the list")
+ }
+}
+
+// The whole CSRF defence is the origin check; a route that skips it is
+// a route anybody's page can post to on a signed-in person's behalf.
+func TestCSRFRefusesACrossOriginPost(t *testing.T) {
+ app, cookies := signInWithOrigin(t)
+ req := as(postForm("/sheets", url.Values{"title": {"from elsewhere"}}), cookies)
+ req.Header.Set("Origin", "https://evil.example.com")
+ if rec := do(t, app, req); rec.Code != http.StatusForbidden {
+ t.Fatalf("a cross-origin POST = %d, want 403", rec.Code)
+ }
+}
+
+func TestCSRFAllowsASameOriginPost(t *testing.T) {
+ app, cookies := signInWithOrigin(t)
+ req := as(postForm("/sheets", url.Values{"title": {"from here"}}), cookies)
+ req.Header.Set("Origin", testOrigin)
+ if rec := do(t, app, req); rec.Code != http.StatusSeeOther {
+ t.Fatalf("a same-origin POST = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// The grid page must carry the ids and window its script needs, or the
+// client has to go and ask for them and the page reflows on arrival.
+func TestSheetPageCarriesItsIDs(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(postForm("/sheets", url.Values{"title": {"Budget"}}), cookies))
+ loc := rec.Header().Get("Location")
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)).Body.String()
+
+ for _, want := range []string{
+ "data-sheet-id=",
+ "data-tab-id=",
+ fmt.Sprintf(`data-rows="%d"`, defaultWindowRows),
+ fmt.Sprintf(`data-cols="%d"`, defaultWindowCols),
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("the grid page is missing %s", want)
+ }
+ }
+}
diff --git a/internal/sheets/helpers_test.go b/internal/sheets/helpers_test.go
index 97e25f6..ccaab16 100644
--- a/internal/sheets/helpers_test.go
+++ b/internal/sheets/helpers_test.go
@@ -36,7 +36,7 @@ func newTestApp(t *testing.T) *App {
if err != nil {
t.Fatalf("seal.New: %v", err)
}
- return New(d.G, nil, s, func(string) bool { return true })
+ return New(Config{DB: d.G, Sealer: s, Admit: func(string) bool { return true }})
}
// testMember is the actor most service tests write as.
@@ -60,6 +60,18 @@ func testMember(t *testing.T, app *App) Member {
func signIn(t *testing.T) (*App, []*http.Cookie) {
t.Helper()
app, mail := appWithRecorder(t)
+ return app, sessionFor(t, app, mail)
+}
+
+// signInWithOrigin is signIn against an app whose CSRF check is live.
+func signInWithOrigin(t *testing.T) (*App, []*http.Cookie) {
+ t.Helper()
+ app, mail := appWithOrigin(t, testOrigin)
+ return app, sessionFor(t, app, mail)
+}
+
+func sessionFor(t *testing.T, app *App, mail *recorder) []*http.Cookie {
+ t.Helper()
postSignin(t, app, "paul@example.com")
u, err := url.Parse(mail.lastLink(t))
@@ -73,7 +85,7 @@ func signIn(t *testing.T) (*App, []*http.Cookie) {
t.Fatalf("verify set no cookie (status %d, location %q)",
rec.Code, rec.Header().Get("Location"))
}
- return app, cookies
+ return cookies
}
// as attaches a session to a request.
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go
index 9bb5f38..a077b8e 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -11,7 +11,7 @@ import (
func get(t *testing.T, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ New(Config{}).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
diff --git a/internal/sheets/render_test.go b/internal/sheets/render_test.go
index 452814f..9343ee8 100644
--- a/internal/sheets/render_test.go
+++ b/internal/sheets/render_test.go
@@ -26,7 +26,7 @@ func TestEveryTemplateParses(t *testing.T) {
}
func TestStaticAssetsAreServed(t *testing.T) {
- app := New(nil, nil, nil, nil)
+ app := New(Config{})
for _, name := range []string{
"/static/tokens.css",
"/static/theme.css",
@@ -47,7 +47,7 @@ func TestStaticAssetsAreServed(t *testing.T) {
// and still get the new one after a deploy. A page linking the bare
// path would be served stale for as long as the cache lived.
func TestPagesLinkFingerprintedAssets(t *testing.T) {
- rec := do(t, New(nil, nil, nil, nil), httptest.NewRequest(http.MethodGet, "/", nil))
+ rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, "/", nil))
body := rec.Body.String()
if strings.Contains(body, `href="/static/tokens.css"`) {
t.Fatal("the page links tokens.css unfingerprinted; a deploy would not bust the cache")
@@ -63,7 +63,7 @@ func TestPagesLinkFingerprintedAssets(t *testing.T) {
func TestRenderOfAnUnknownPageIs500NotAPanic(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
- New(nil, nil, nil, nil).render(rec, req, "no-such-page", "T", http.StatusOK, nil)
+ New(Config{}).render(rec, req, "no-such-page", "T", http.StatusOK, nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("rendering an unknown page = %d, want 500", rec.Code)
}
diff --git a/internal/sheets/sheetsvc.go b/internal/sheets/sheetsvc.go
index dc08f86..5e6329d 100644
--- a/internal/sheets/sheetsvc.go
+++ b/internal/sheets/sheetsvc.go
@@ -21,6 +21,24 @@ const maxTitleRunes = 200
// sealed name uses.
const firstTabName = "Sheet 1"
+// The grid paints a window, never a whole tab. Spec §6's reasoning for
+// the 200,000-cell cap is that a tab rendered whole is a
+// several-hundred-megabyte page; a window is the smallest thing that
+// honours it, and it is a window rather than virtual scrolling because
+// virtualisation is a lot of client code to write before anybody has
+// typed into a single cell.
+//
+// defaultWindow* is what a page opens with. maxWindow* is what a
+// request may ask for: past it the numbers are clamped, not refused,
+// because refusing turns a too-wide window into a broken page while
+// clamping turns it into a slightly smaller one.
+const (
+ defaultWindowRows = 100
+ defaultWindowCols = 26 // A..Z, which is what a person expects to see
+ maxWindowRows = 500
+ maxWindowCols = 60
+)
+
// errNoSealer is returned rather than panicking when an App built
// without a sealer (page-rendering tests) reaches a path that needs one.
var errNoSealer = errors.New("sheets: no sealer configured")
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go
index a00f8ad..c42b260 100644
--- a/internal/sheets/signin_test.go
+++ b/internal/sheets/signin_test.go
@@ -47,7 +47,7 @@ func TestSigninMessageSentAndEmpty(t *testing.T) {
func TestSigninPageDoesNotEchoQuery(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/signin?err=%3Cscript%3Ealert(1)%3C/script%3E", nil)
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+ New(Config{}).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /signin = %d", rec.Code)
}
@@ -80,7 +80,7 @@ func testAuth(t *testing.T) *App {
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
- return New(d.G, a, nil, Admits([]string{"paul@example.com"}))
+ return New(Config{DB: d.G, Auth: a, Admit: Admits([]string{"paul@example.com"})})
}
// TestAuthRoutesAreMounted proves the four handlers are reachable. It
@@ -107,7 +107,7 @@ func TestAuthRoutesAreMounted(t *testing.T) {
// pins that those codes really do mean "not mounted", so the assertion
// there is measuring what it claims to.
func TestAuthRoutesAbsentWithoutAuth(t *testing.T) {
- app := New(nil, nil, nil, nil)
+ app := New(Config{})
for _, tc := range []struct{ method, path string }{
{http.MethodPost, "/signin"},
{http.MethodGet, "/auth/verify"},
@@ -126,7 +126,7 @@ func TestAuthRoutesAbsentWithoutAuth(t *testing.T) {
func TestSigninCarriesForce(t *testing.T) {
body := func(url string) string {
rec := httptest.NewRecorder()
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil))
+ New(Config{}).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil))
return rec.Body.String()
}
with := body("/signin?force=1&err=keymail")
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
new file mode 100644
index 0000000..ca706ec
--- /dev/null
+++ b/internal/sheets/templates/sheet.html
@@ -0,0 +1,42 @@
+{{define "main"}}
+{{/* The grid gets its own <main>, without rst-page's padding and max
+ width: a spreadsheet is the one screen in this app that should fill
+ the viewport. Everything else uses the layout's default. */}}
+<main data-sheet-surface id="main"
+ data-sheet-id="{{.Data.Sheet.ID}}"
+ data-tab-id="{{.Data.Sheet.TabID}}"
+ data-rows="{{.Data.Rows}}"
+ data-cols="{{.Data.Cols}}">
+{{template "content" .}}
+</main>
+{{end}}
+
+{{define "content"}}
+<div data-sheet-bar>
+<span data-sheet-title>{{.Data.Sheet.Title}}</span>
+{{/* Every drag-only affordance has a menu equivalent (AGENTS.md).
+ Fill-down and fill-right are the two the grid offers on a drag
+ handle, so they are buttons here as well — reachable by keyboard,
+ and reachable at all on a touch screen. */}}
+<button rst-btn="ghost" type="button" data-action="fill-down" hidden>Fill down</button>
+<button rst-btn="ghost" type="button" data-action="fill-right" hidden>Fill right</button>
+<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/table">Table view</a>
+<span data-sheet-live data-state="off" role="status">Not connected</span>
+</div>
+
+<noscript>
+{{/* Spec §6 names the grid editor, drag-fill and live sync as the
+ script-required parts, and everything else as scriptless. A visitor
+ with scripting off should land somewhere useful rather than on an
+ empty div, so this is a link and not an apology. */}}
+<div rst-callout>
+<div rst-callout-body>
+<p>The grid needs JavaScript. Everything it does, the
+<a href="/sheets/{{.Data.Sheet.ID}}/table">table view</a> also does —
+read every cell, edit any of them, one page at a time.</p>
+</div>
+</div>
+</noscript>
+
+<div data-sheet-grid></div>
+{{end}}
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html
new file mode 100644
index 0000000..3dbac96
--- /dev/null
+++ b/internal/sheets/templates/sheets.html
@@ -0,0 +1,37 @@
+{{define "content"}}
+<header rst-page-header>
+<div rst-page-header-titles>
+<h1>Your sheets</h1>
+<p rst-page-header-sub>Everyone on this instance can open every sheet here.</p>
+</div>
+</header>
+
+<form rst-form method="post" action="/sheets">
+<div rst-field-row>
+<div rst-field>
+<label rst-field-label for="title">New sheet</label>
+<input rst-input id="title" name="title" type="text" placeholder="Untitled sheet" autocomplete="off">
+</div>
+<button rst-btn="primary" type="submit">Create</button>
+</div>
+</form>
+
+{{if .Data.Sheets}}
+<ul rst-list>
+{{range .Data.Sheets}}
+<li rst-lrow>
+<div rst-row-main>
+<a href="/sheets/{{.ID}}">{{.Title}}</a>
+<span rst-row-sub>Last changed {{.UpdatedAt.Format "2 Jan 2006, 15:04"}}</span>
+</div>
+</li>
+{{end}}
+</ul>
+{{else}}
+<div rst-empty>
+<p rst-empty-title>No sheets yet</p>
+<p rst-empty-body>Make one above. It opens with a blank grid you can type
+into straight away.</p>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/sheets/theme_test.go b/internal/sheets/theme_test.go
index 6c051ec..5a2a62f 100644
--- a/internal/sheets/theme_test.go
+++ b/internal/sheets/theme_test.go
@@ -45,7 +45,7 @@ func TestThemeIsInTheFirstResponseNotFetched(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+ New(Config{}).Router().ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `data-theme="signal"`) {
t.Fatal("the rendered page does not carry the theme; it would flash on load")
@@ -57,7 +57,7 @@ func TestPostThemeSetsTheCookieAndReturns(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+ New(Config{}).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /theme = %d, want 303", rec.Code)
@@ -81,7 +81,7 @@ func TestPostThemeRefusesAnUnknownTheme(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
- New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+ New(Config{}).Router().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /theme with an unknown theme = %d, want 400", rec.Code)
}