Expose native Docs metadata and sharing with current session checks
10 files changed,
+369
−2
go.mod+2 −1go.sum+2 −0internal/docs/app.go+26 −0internal/docs/handlers.go+14 −1internal/docs/native.go+80 −0internal/docs/native_stream_test.go+55 −0internal/docs/native_test.go+162 −0internal/docs/publish.go+4 −0internal/docs/share.go+20 −0internal/docs/stream.go+4 −0
diff --git a/go.mod b/go.mod| index a3bec91..55dcef6 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,11 +6,13 @@ require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| + amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| github.com/google/uuid v1.6.0 |
| + github.com/keymaildev/signin v0.1.1 |
| github.com/tetratelabs/wazero v1.12.0 |
| golang.org/x/text v0.20.0 |
| gorm.io/gorm v1.31.2 |
| @@ -21,7 +23,6 @@ require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| - github.com/keymaildev/signin v0.1.1 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
diff --git a/go.sum b/go.sum| index f458953..106177a 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -4,6 +4,8 @@ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg= |
| +amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734 h1:hI8/x1OEHCragb3bclo1X2bI6XCO1DDQ3plWcQPbI0g= |
| +amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 h1:/vHMeq9T+PZmUACixGkg32YEjyM8s31+bfAqqrMha0A= |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 1e2e5aa..01e27e3 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -21,6 +21,7 @@ import ( |
| "time" |
| "amadan.net/oficina/home/handoff" |
| + nativeauth "amadan.net/rastrillo/native/server" |
| "github.com/carlosframework/docs/internal/docs/linkstore" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/mail" |
| @@ -481,13 +482,38 @@ func New(cfg Config) (*App, error) { |
| } |
| } |
| + // Approval and long-lived streams reuse the same current admission chain. |
| + h.requireCurrent = func(next http.Handler) http.Handler { |
| + if store.team != nil { |
| + next = store.team.admission(next) |
| + } else { |
| + next = requireAdmission(list, "/signin")(next) |
| + } |
| + if home == nil { |
| + return authPlugin.RequireSession(next) |
| + } |
| + return home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next)) |
| + } |
| + nativeSessions, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.Origin, SigninPath: "/signin", Logger: logger}) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + d.Close() |
| + return nil, err |
| + } |
| + native := nativeauth.New(nativeSessions, cfg.Origin, "Docs", h.requireCurrent) |
| r := chi.NewRouter() |
| + r.Use(native.Resume) |
| // Mounted above every route, per rastrillo SKILL.md ยง5: refuses a |
| // cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/ |
| // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does |
| // nothing to "/" or "/d/{sqid}" and everything to "/documents", |
| // "/signin" and "/signout". |
| r.Use(homeCSRF(cfg.Origin, home)) |
| + r.Post("/native/request", native.ServeHTTP) |
| + r.Post("/native/exchange", native.ServeHTTP) |
| + r.Get("/native/start", native.ServeHTTP) |
| + r.Get("/native/authorize", native.ServeHTTP) |
| + r.Post("/native/authorize", native.ServeHTTP) |
| // Unauthenticated: the sign-in page and its four handlers, and the |
| // static assets that page (and the shell around it) needs to |
| // render at all. |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 5fd579c..a0b6c73 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -34,7 +34,8 @@ const maxUpdateBytes = 1 << 20 |
| // instead: this app reads and writes documents through Store, never |
| // through a *gorm.DB of its own. |
| type handlers struct { |
| - homeEnabled bool |
| + requireCurrent func(http.Handler) http.Handler |
| + homeEnabled bool |
| render *renderer |
| logger *slog.Logger |
| @@ -243,6 +244,10 @@ func (h *handlers) member(r *http.Request) bool { |
| // the page โ rendering it would be inventing display data, not |
| // showing real ownership. |
| func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| + if wantsNative(r) { |
| + h.nativeList(w, r) |
| + return |
| + } |
| // actor, not owner: this page answers "what can I reach", which |
| // since sharing landed is a wider question than "what is mine". |
| who, err := h.actor(r) |
| @@ -353,6 +358,10 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| + if wantsNative(r) { |
| + nativeJSON(w, r, 201, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID}) |
| + return |
| + } |
| http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther) |
| } |
| @@ -562,6 +571,10 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + if wantsNative(r) { |
| + nativeJSON(w, r, 200, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID, "can_share": doc.OwnerID == actor.PersonID && h.member(r), "can_write": h.store.CanWrite(r.Context(), actor, doc.ID)}) |
| + return |
| + } |
| html, err := h.store.HTML(r.Context(), doc) |
| if err != nil { |
| h.logger.Error("docs: document html", "sqid", sqid, "err", err) |
diff --git a/internal/docs/native.go b/internal/docs/native.go| new file mode 100644 |
| index 0000000..025d9a0 |
| --- /dev/null |
| +++ b/internal/docs/native.go |
| @@ -0,0 +1,80 @@ |
| +package docs |
| + |
| +import ( |
| + "encoding/json" |
| + "github.com/carlosframework/rastrillo" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +func wantsNative(r *http.Request) bool { |
| + return strings.Contains(r.Header.Get("Accept"), "application/json") |
| +} |
| + |
| +func nativeJSON(w http.ResponseWriter, r *http.Request, status int, value any) { |
| + body, err := json.Marshal(value) |
| + if err != nil { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Add("Vary", "Accept") |
| + _ = writeGuardedBody(w, r, status, body) |
| +} |
| + |
| +func (h *handlers) nativeList(w http.ResponseWriter, r *http.Request) { |
| + actor, err := h.actor(r) |
| + if err != nil { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusUnauthorized) |
| + return |
| + } |
| + var documents []*Document |
| + if query := strings.TrimSpace(r.URL.Query().Get("q")); query != "" { |
| + documents, err = h.store.SearchFor(r.Context(), actor, query) |
| + } else { |
| + documents, err = h.store.ListFor(r.Context(), actor) |
| + } |
| + if err != nil { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| + return |
| + } |
| + type item struct { |
| + Sqid string `json:"sqid"` |
| + Title string `json:"title"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| + Shared bool `json:"shared"` |
| + Thumbnail thumbView `json:"thumbnail"` |
| + } |
| + items := make([]item, 0, len(documents)) |
| + thumbnail := h.thumbnailer(r.Context()) |
| + for _, doc := range documents { |
| + items = append(items, item{doc.Sqid, doc.Title, doc.UpdatedAt, doc.OwnerID != actor.PersonID, thumbnail(doc)}) |
| + } |
| + nativeJSON(w, r, 200, map[string]any{"documents": items, "can_create": h.member(r)}) |
| +} |
| + |
| +// The authentication middleware is rerun against the cookie before every |
| +// stream write. A context captured when the stream opened is not current proof. |
| +func (h *handlers) nativeStreamAllowed(r *http.Request, sqid string) bool { |
| + if h.requireCurrent == nil { |
| + return true |
| + } // isolated handler fixtures |
| + allowed := false |
| + h.requireCurrent(http.HandlerFunc(func(_ http.ResponseWriter, fresh *http.Request) { |
| + actor, err := h.actor(fresh) |
| + if err != nil { |
| + return |
| + } |
| + _, err = h.store.GetFor(fresh.Context(), actor, sqid) |
| + allowed = err == nil |
| + })).ServeHTTP(nativeDiscard{}, r) |
| + return allowed |
| +} |
| + |
| +type nativeDiscard struct{} |
| + |
| +func (nativeDiscard) Header() http.Header { return make(http.Header) } |
| +func (nativeDiscard) Write(p []byte) (int, error) { return len(p), nil } |
| +func (nativeDiscard) WriteHeader(int) {} |
diff --git a/internal/docs/native_stream_test.go b/internal/docs/native_stream_test.go| new file mode 100644 |
| index 0000000..f760928 |
| --- /dev/null |
| +++ b/internal/docs/native_stream_test.go |
| @@ -0,0 +1,55 @@ |
| +package docs |
| + |
| +import ( |
| + "bufio" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestNativeStreamStopsAfterSessionRevocation(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := app.TestPerson(t, "stream@example.com") |
| + doc, err := app.Store().Create(t.Context(), owner, "Live fixture") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + streamDurations(t, app, 20*time.Millisecond, time.Second) |
| + server := httptest.NewServer(app.Handler()) |
| + defer server.Close() |
| + authenticated := app.TestRequest(t, "stream@example.com", "GET", "/", nil) |
| + req, _ := http.NewRequest("GET", server.URL+"/d/"+doc.Sqid+"/stream", nil) |
| + for _, cookie := range authenticated.Cookies() { |
| + req.AddCookie(cookie) |
| + } |
| + response, err := server.Client().Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer response.Body.Close() |
| + reader := bufio.NewReader(response.Body) |
| + if name, _ := nextEvent(t, reader, time.Second); name != "update" { |
| + t.Fatalf("no initial event: %s", name) |
| + } |
| + signout := httptest.NewRequest("POST", "/signout", nil) |
| + signout.Header.Set("Sec-Fetch-Site", "same-origin") |
| + for _, cookie := range authenticated.Cookies() { |
| + signout.AddCookie(cookie) |
| + } |
| + app.Handler().ServeHTTP(httptest.NewRecorder(), signout) |
| + closed := make(chan error, 1) |
| + go func() { _, err := io.Copy(io.Discard, reader); closed <- err }() |
| + select { |
| + case err := <-closed: |
| + if err != nil { |
| + t.Fatalf("stream failed instead of clean revocation: %v", err) |
| + } |
| + case <-time.After(300 * time.Millisecond): |
| + t.Fatal("revoked session retains stream until TTL") |
| + } |
| +} |
diff --git a/internal/docs/native_test.go b/internal/docs/native_test.go| new file mode 100644 |
| index 0000000..7402a12 |
| --- /dev/null |
| +++ b/internal/docs/native_test.go |
| @@ -0,0 +1,162 @@ |
| +package docs_test |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func TestNativeProofAndSessionRevocation(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + browser := signInCookies(t, app, "alice@example.com") |
| + verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) |
| + hash := sha256.Sum256([]byte(verifier)) |
| + begin := nativeRequest(app, nil, "POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`) |
| + var pending struct{ ID string } |
| + if begin.Code != 200 || json.Unmarshal(begin.Body.Bytes(), &pending) != nil || pending.ID == "" { |
| + t.Fatalf("native begin: %d", begin.Code) |
| + } |
| + exchange := `{"id":"` + pending.ID + `","verifier":"` + verifier + `"}` |
| + if got := nativeRequest(app, nil, "POST", "/native/exchange", exchange); got.Code != 202 || strings.Contains(got.Body.String(), `"value"`) { |
| + t.Fatalf("pending leaks credential: %d", got.Code) |
| + } |
| + wrong := nativeRequest(app, nil, "POST", "/native/exchange", `{"id":"`+pending.ID+`","verifier":"wrong"}`) |
| + if wrong.Code == 200 { |
| + t.Fatal("wrong proof accepted") |
| + } |
| + if got := nativeRequest(app, browser, "POST", "/native/authorize?id="+pending.ID, ""); got.Code != 200 { |
| + t.Fatalf("approve: %d", got.Code) |
| + } |
| + done := nativeRequest(app, nil, "POST", "/native/exchange", exchange) |
| + var credential struct{ Name, Value string } |
| + if done.Code != 200 || json.Unmarshal(done.Body.Bytes(), &credential) != nil || credential.Value == "" { |
| + t.Fatalf("exchange: %d", done.Code) |
| + } |
| + native := []*http.Cookie{{Name: credential.Name, Value: credential.Value}} |
| + if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 200 { |
| + t.Fatalf("native session rejected: %d", got.Code) |
| + } |
| + nativeRequest(app, native, "POST", "/signout", "") |
| + if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 303 { |
| + t.Fatalf("revoked native session accepted: %d", got.Code) |
| + } |
| + if got := nativeRequest(app, browser, "GET", "/", ""); got.Code != 200 { |
| + t.Fatalf("native signout incorrectly revoked browser: %d", got.Code) |
| + } |
| + request := httptest.NewRequest("POST", "/native/request", strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`)) |
| + request.Header.Set("Origin", "https://foreign.invalid") |
| + request.Header.Set("Sec-Fetch-Site", "cross-site") |
| + recorder := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(recorder, request) |
| + if recorder.Code != 403 { |
| + t.Fatalf("cross-origin native begin: %d", recorder.Code) |
| + } |
| +} |
| + |
| +func nativeRequest(app *docs.App, cookies []*http.Cookie, method, path, form string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, path, strings.NewReader(form)) |
| + r.Header.Set("Accept", "application/json") |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + if strings.HasPrefix(form, "{") { |
| + r.Header.Set("Content-Type", "application/json") |
| + } |
| + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| + for _, cookie := range cookies { |
| + r.AddCookie(cookie) |
| + } |
| + w := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +func TestNativeMetadataPreservesPermissions(t *testing.T) { |
| + app := members(t, "alice@example.com,bob@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + bob := signInCookies(t, app, "bob@example.com") |
| + created := nativeRequest(app, alice, "POST", "/documents", "title=Native+minutes") |
| + if created.Code != 201 { |
| + t.Fatalf("create: %d %s", created.Code, created.Body.String()) |
| + } |
| + var document struct{ Sqid, Title, GUID string } |
| + if err := json.Unmarshal(created.Body.Bytes(), &document); err != nil || document.Sqid == "" || document.GUID == "" { |
| + t.Fatalf("missing native identity: %s", created.Body.String()) |
| + } |
| + path := "/d/" + document.Sqid |
| + if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 { |
| + t.Fatalf("foreign document: %d", got.Code) |
| + } |
| + grant := nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=reader") |
| + if grant.Code != 200 { |
| + t.Fatalf("grant: %d", grant.Code) |
| + } |
| + got := nativeRequest(app, bob, "GET", path, "") |
| + var detail struct { |
| + CanWrite bool `json:"can_write"` |
| + CanShare bool `json:"can_share"` |
| + GUID string `json:"guid"` |
| + } |
| + if got.Code != 200 || json.Unmarshal(got.Body.Bytes(), &detail) != nil || detail.GUID == "" || detail.CanWrite || detail.CanShare { |
| + t.Fatalf("reader metadata: %d %s", got.Code, got.Body.String()) |
| + } |
| + if got.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("native content is cacheable") |
| + } |
| + if got := nativeRequest(app, bob, "GET", path+"/share", ""); got.Code != 404 { |
| + t.Fatalf("reader sees sharing: %d", got.Code) |
| + } |
| + listing := nativeRequest(app, bob, "GET", "/?q=Native", "") |
| + if listing.Code != 200 || !strings.Contains(listing.Body.String(), document.Sqid) { |
| + t.Fatalf("shared search missing: %s", listing.Body.String()) |
| + } |
| + revoked := nativeRequest(app, alice, "POST", path+"/share/revoke", "address=bob%40example.com") |
| + if revoked.Code != 200 { |
| + t.Fatalf("revoke: %d", revoked.Code) |
| + } |
| + if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 { |
| + t.Fatalf("revoked reader: %d", got.Code) |
| + } |
| + listing = nativeRequest(app, bob, "GET", "/", "") |
| + if strings.Contains(listing.Body.String(), document.Sqid) { |
| + t.Fatal("revoked document remains in native list") |
| + } |
| +} |
| + |
| +func TestNativePublicLinkShownOnceAndAnonymousRefused(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Published native") |
| + path := "/d/" + sqid + "/share" |
| + pub := nativeRequest(app, alice, "POST", path+"/link", "on=1") |
| + var result struct { |
| + Published bool `json:"published"` |
| + URL string `json:"public_url"` |
| + } |
| + if pub.Code != 200 || json.Unmarshal(pub.Body.Bytes(), &result) != nil || !result.Published || result.URL == "" { |
| + t.Fatalf("publish: %d %s", pub.Code, pub.Body.String()) |
| + } |
| + if pub.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("public token response cacheable") |
| + } |
| + url, err := url.Parse(result.URL) |
| + if err != nil || !strings.HasPrefix(url.Path, "/p/") { |
| + t.Fatal("invalid public link") |
| + } |
| + next := nativeRequest(app, alice, "GET", path, "") |
| + if next.Code != 200 || strings.Contains(next.Body.String(), result.URL) { |
| + t.Fatal("public token returned twice") |
| + } |
| + if got := nativeRequest(app, nil, "GET", "/", ""); got.Code != 303 { |
| + t.Fatalf("anonymous native listing: %d", got.Code) |
| + } |
| + if got := nativeRequest(app, nil, "POST", "/documents", "title=No"); got.Code != 403 { |
| + t.Fatalf("anonymous native creation: %d", got.Code) |
| + } |
| +} |
diff --git a/internal/docs/publish.go b/internal/docs/publish.go| index 3a4ea8d..361a31a 100644 |
| --- a/internal/docs/publish.go |
| +++ b/internal/docs/publish.go |
| @@ -124,6 +124,10 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| } |
| switch { |
| case err == nil: |
| + if wantsNative(r) { |
| + nativeJSON(w, r, 200, map[string]any{"published": on, "public_url": h.oneShots.take(oneShotKey(owner, sqid))}) |
| + return |
| + } |
| http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| case errors.Is(err, gorm.ErrRecordNotFound): |
| http.NotFound(w, r) |
diff --git a/internal/docs/share.go b/internal/docs/share.go| index 6bd3f01..35554f0 100644 |
| --- a/internal/docs/share.go |
| +++ b/internal/docs/share.go |
| @@ -108,6 +108,18 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| // without it, exactly as a visit tomorrow would. |
| publicURL := h.oneShots.take(oneShotKey(doc.OwnerID, doc.Sqid)) |
| + if wantsNative(r) { |
| + type grant struct { |
| + Address string `json:"address"` |
| + Role Role `json:"role"` |
| + } |
| + rows := make([]grant, 0, len(grants)) |
| + for _, g := range grants { |
| + rows = append(rows, grant{g.Subject, g.Role}) |
| + } |
| + nativeJSON(w, r, status, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "grants": rows, "published": published, "public_url": publicURL, "error": errKey}) |
| + return |
| + } |
| rows := make([]shareRow, 0, len(grants)) |
| for _, g := range grants { |
| label := "docs.share.role.reader" |
| @@ -196,6 +208,10 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| err = h.store.Grant(r.Context(), owner, sqid, r.PostFormValue("address"), role) |
| switch { |
| case err == nil: |
| + if wantsNative(r) { |
| + nativeJSON(w, r, 200, map[string]bool{"ok": true}) |
| + return |
| + } |
| http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| case errors.Is(err, errEmptyAddress): |
| // Re-render with the message rather than redirecting, so the person |
| @@ -239,6 +255,10 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| + if wantsNative(r) { |
| + nativeJSON(w, r, 200, map[string]bool{"ok": true}) |
| + return |
| + } |
| http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| } |
diff --git a/internal/docs/stream.go b/internal/docs/stream.go| index 9469f18..abdc6c5 100644 |
| --- a/internal/docs/stream.go |
| +++ b/internal/docs/stream.go |
| @@ -141,6 +141,10 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
| // unbounded โ while a write error ends the handler, because it |
| // means the client is gone. |
| write := func(text string) bool { |
| + // Team streams already revalidate through their original bounded lease. |
| + if guard == nil && !h.nativeStreamAllowed(r, doc.Sqid) { |
| + return false |
| + } |
| limit := defaultStreamWriteDeadline |
| if guard != nil { |
| var ok bool |