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
14 files changed,
+530
−37
cmd/sheets/main.go+7 −1internal/sheets/app.go+92 −21internal/sheets/app_test.go+1 −1internal/sheets/begin_test.go+13 −1internal/sheets/handlers_sheets.go+119 −0internal/sheets/handlers_sheets_test.go+176 −0internal/sheets/helpers_test.go+14 −2internal/sheets/page_test.go+1 −1internal/sheets/render_test.go+3 −3internal/sheets/sheetsvc.go+18 −0internal/sheets/signin_test.go+4 −4internal/sheets/templates/sheet.html+42 −0internal/sheets/templates/sheets.html+37 −0internal/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) |
| } |