oficina / docs Public

Guard buffered TeamMode responses and retryable handler failures

Paul Campbell pushed by paul@keymail.dev dd1f1262394ca8b6635a3f5157f415904e7ec537 parent abebdd1
12 files changed, +314 −73
  • internal/docs/export.go +5 −5
  • internal/docs/grants.go +2 −0
  • internal/docs/handlers.go +36 −34
  • internal/docs/publish.go +6 −7
  • internal/docs/render.go +1 −5
  • internal/docs/search.go +4 −4
  • internal/docs/share.go +10 −12
  • internal/docs/store.go +2 −0
  • internal/docs/stream.go +2 −2
  • internal/docs/team_access.go +40 −4
  • internal/docs/team_emission.go +124 −0
  • internal/docs/team_emission_test.go +82 −0

CI passed — run details

diff --git a/internal/docs/export.go b/internal/docs/export.go
index 82fd838..5f5ddd7 100644
--- a/internal/docs/export.go
+++ b/internal/docs/export.go
@@ -223,7 +223,7 @@ func (h *handlers) exportMarkdown(w http.ResponseWriter, r *http.Request) {
// latin-1 turns every em dash into mojibake.
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
setDownloadName(w, doc, ".md")
- if _, err := w.Write([]byte(body)); err != nil {
+ if err := writeGuardedBody(w, r, 0, []byte(body)); err != nil {
h.logger.Error("docs: write markdown export", "sqid", doc.Sqid, "err", err)
}
}
@@ -238,7 +238,7 @@ func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) {
body, report, err := export.PDF(structure)
if err != nil {
h.logger.Error("docs: render pdf export", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
if !report.Empty() {
@@ -271,7 +271,7 @@ func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
setDownloadName(w, doc, ".pdf")
- if _, err := w.Write(body); err != nil {
+ if err := writeGuardedBody(w, r, 0, body); err != nil {
h.logger.Error("docs: write pdf export", "sqid", doc.Sqid, "err", err)
}
}
@@ -288,14 +288,14 @@ func (h *handlers) documentForExport(w http.ResponseWriter, r *http.Request) (*D
structureJSON, err := h.store.JSON(r.Context(), doc)
if err != nil {
h.logger.Error("docs: document structure", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return nil, export.Document{}, false
}
structure, err := export.Parse(doc.Title, structureJSON)
if err != nil {
h.logger.Error("docs: parse document structure", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return nil, export.Document{}, false
}
return doc, structure, true
diff --git a/internal/docs/grants.go b/internal/docs/grants.go
index fca5936..a91518f 100644
--- a/internal/docs/grants.go
+++ b/internal/docs/grants.go
@@ -296,6 +296,7 @@ func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, er
if err != nil {
return nil, s.permissionError(ctx, a, err)
}
+ s.rememberResponse(ctx, permissionRead, &doc)
return &doc, nil
}
@@ -350,5 +351,6 @@ func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) {
return nil, errTeamUnavailable
}
}
+ s.rememberResponse(ctx, permissionRead, docs...)
return docs, nil
}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 81402d8..24d9e92 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -1,6 +1,7 @@
package docs
import (
+ "amadan.net/oficina/home/roster"
"errors"
"html/template"
"io"
@@ -208,6 +209,10 @@ func (h *handlers) actor(r *http.Request) (Actor, error) {
// members — the guest was invited by one. Pretending POST /documents does
// not exist would be a lie told for no gain.
func (h *handlers) member(r *http.Request) bool {
+ if h.store.team != nil {
+ a, ok := r.Context().Value(accessContextKey{}).(*requestAccess)
+ return ok && a != nil && a.runtime == h.store.team && a.state() == roster.Member
+ }
id, err := requestActor(r, h.store)
if err != nil {
return false
@@ -242,7 +247,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
who, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -276,7 +281,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
hits, err := h.store.SearchFor(r.Context(), who, query)
if err != nil {
h.logger.Error("docs: search documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
now := time.Now().UTC()
@@ -293,7 +298,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
}
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
return
}
@@ -301,13 +306,13 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
docs, err := h.store.ListFor(r.Context(), who)
if err != nil {
h.logger.Error("docs: list documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()), who.PersonID)
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
@@ -325,14 +330,13 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
// minting documents of their own is not that. Without this, one grant
// is a transitive key to the front door — the guest creates, shares,
// and admits the next person (branch review, finding 1).
- if !h.member(r) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ if !h.requireMemberRequest(w, r, false) {
return
}
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -344,7 +348,7 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
doc, err := h.store.Create(r.Context(), owner, title)
if err != nil {
h.logger.Error("docs: create document", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
@@ -539,7 +543,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -552,14 +556,14 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
return
}
h.logger.Error("docs: get document", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
html, err := h.store.HTML(r.Context(), doc)
if err != nil {
h.logger.Error("docs: document html", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -570,7 +574,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
SignOutLabel: rastrillo.T(r, "docs.signout.label"),
BackLabel: rastrillo.T(r, "docs.list.title"),
Sqid: doc.Sqid,
- CanShare: doc.OwnerID == actor.PersonID,
+ CanShare: doc.OwnerID == actor.PersonID && h.member(r),
CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID),
ShareLabel: rastrillo.T(r, "docs.share.title"),
EditorScript: editorScriptPath(),
@@ -587,7 +591,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
}
if err := h.render.page(w, r, "show", data); err != nil {
h.logger.Error("docs: render show", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
@@ -613,7 +617,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request)
actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return nil, false
}
@@ -627,7 +631,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request)
return nil, false
}
h.logger.Error("docs: get document", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return nil, false
}
return doc, true
@@ -705,14 +709,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
Order("seq ASC").
Find(&updates).Error; err != nil {
h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
for i := range updates {
if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil {
h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
}
@@ -726,7 +730,7 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
}
if total > maxLogBytes {
h.logger.Error("docs: update log exceeds maxLogBytes", "sqid", doc.Sqid, "bytes", total, "cap", maxLogBytes)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -750,21 +754,15 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Docs-Seq", strconv.FormatInt(highest, 10))
+ parts := make([][]byte, 0, len(updates)+1)
if snapshot != nil {
- if _, err := w.Write(snapshot.State); err != nil {
- // See below: the client went away mid-response.
- h.logger.Error("docs: write snapshot", "sqid", doc.Sqid, "err", err)
- return
- }
+ parts = append(parts, snapshot.State)
}
for _, u := range updates {
- if _, err := w.Write(u.UpdateBin); err != nil {
- // The client went away mid-response; nothing left to do
- // but stop writing. Logged, not surfaced — the status
- // line is already on the wire.
- h.logger.Error("docs: write update log", "sqid", doc.Sqid, "err", err)
- return
- }
+ parts = append(parts, u.UpdateBin)
+ }
+ if err := writeGuardedBody(w, r, 0, parts...); err != nil {
+ h.logger.Error("docs: write update log", "err", err)
}
}
@@ -793,14 +791,14 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) {
return
}
h.logger.Error("docs: read posted update", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve actor for update", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -818,6 +816,10 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
return
}
+ if errors.Is(err, errTeamUnavailable) {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ return
+ }
if updateIsClientFault(err) {
// Info, not Error: a client sending bytes the core
// cleanly rejects is an ordinary, expected outcome of
@@ -832,7 +834,7 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) {
return
}
h.logger.Error("docs: apply posted update", "sqid", doc.Sqid, "doc_id", doc.ID, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
diff --git a/internal/docs/publish.go b/internal/docs/publish.go
index 48562dd..3a4ea8d 100644
--- a/internal/docs/publish.go
+++ b/internal/docs/publish.go
@@ -50,14 +50,14 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) {
return
}
h.logger.Error("docs: resolve public link", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
html, err := h.store.HTML(r.Context(), doc)
if err != nil {
h.logger.Error("docs: public document html", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -81,7 +81,7 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) {
ReadOnly: rastrillo.T(r, "docs.public.readonly"),
}); err != nil {
h.logger.Error("docs: render public document", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
@@ -98,14 +98,13 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) {
func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) {
// Member-only, as the other two share routes are. Publishing to the
// whole internet is the largest re-share there is.
- if !h.member(r) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ if !h.requireMemberRequest(w, r, true) {
return
}
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for publish", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
sqid := r.PathValue("sqid")
@@ -130,6 +129,6 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
default:
h.logger.Error("docs: set public link", "sqid", sqid, "on", on, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 8dc5ac7..59643dd 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -577,11 +577,7 @@ func (rd *renderer) pageStatus(w http.ResponseWriter, r *http.Request, name stri
return fmt.Errorf("docs: render page %q: %w", name, err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if status != 0 {
- w.WriteHeader(status)
- }
- _, err := buf.WriteTo(w)
- return err
+ return writeGuardedBody(w, r, status, buf.Bytes())
}
// vendoredStaticFS builds the static/ tree rastrillo.Assets serves —
diff --git a/internal/docs/search.go b/internal/docs/search.go
index f1a0f83..5046f96 100644
--- a/internal/docs/search.go
+++ b/internal/docs/search.go
@@ -212,14 +212,14 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
who, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
docs, err := h.store.SearchFor(r.Context(), who, r.URL.Query().Get("q"))
if err != nil {
h.logger.Error("docs: search documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -239,14 +239,14 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(results)
if err != nil {
h.logger.Error("docs: encode search results", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// A person's own document titles, and they change as they type.
w.Header().Set("Cache-Control", "no-store")
- if _, err := w.Write(body); err != nil {
+ if err := writeGuardedBody(w, r, 0, body); err != nil {
h.logger.Error("docs: write search results", "err", err)
}
}
diff --git a/internal/docs/share.go b/internal/docs/share.go
index 1ee4c20..ddfdd04 100644
--- a/internal/docs/share.go
+++ b/internal/docs/share.go
@@ -68,7 +68,7 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for share", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
sqid := r.PathValue("sqid")
@@ -81,7 +81,7 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
grants, err := h.store.ListGrants(r.Context(), owner, sqid)
if err != nil {
h.logger.Error("docs: list grants", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -154,7 +154,7 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
}
if err := h.render.page(w, r, "share", page); err != nil {
h.logger.Error("docs: render share", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
@@ -167,14 +167,13 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
// a document is a member power. A guest owns no documents (create is
// closed to them), so this is belt and braces — and it is the brace
// that holds if a guest ever acquires one.
- if !h.member(r) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ if !h.requireMemberRequest(w, r, true) {
return
}
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for share", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
sqid := r.PathValue("sqid")
@@ -200,7 +199,7 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
grants, listErr := h.store.ListGrants(r.Context(), owner, sqid)
if listErr != nil {
h.logger.Error("docs: list grants after refusal", "sqid", sqid, "err", listErr)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
// Content-Type before WriteHeader, or the header render.page
@@ -211,7 +210,7 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
h.renderShare(w, r, doc, grants, "docs.share.error.address")
default:
h.logger.Error("docs: grant", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
}
}
@@ -221,14 +220,13 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
// a document is a member power. A guest owns no documents (create is
// closed to them), so this is belt and braces — and it is the brace
// that holds if a guest ever acquires one.
- if !h.member(r) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ if !h.requireMemberRequest(w, r, true) {
return
}
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for revoke", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
sqid := r.PathValue("sqid")
@@ -239,7 +237,7 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
}
if err := h.store.Revoke(r.Context(), owner, sqid, r.PostFormValue("address")); err != nil {
h.logger.Error("docs: revoke", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
diff --git a/internal/docs/store.go b/internal/docs/store.go
index 47d4e8e..f96c9bd 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -347,6 +347,7 @@ func (s *Store) List(ctx context.Context, owner int64) ([]*Document, error) {
if err != nil {
return nil, fmt.Errorf("docs: list documents: %w", err)
}
+ s.rememberResponse(ctx, permissionOwner, docs...)
return docs, nil
}
@@ -364,6 +365,7 @@ func (s *Store) Get(ctx context.Context, owner int64, sqid string) (*Document, e
if err != nil {
return nil, s.permissionError(ctx, s.ownerActor(ctx, owner), fmt.Errorf("docs: get document %q: %w", sqid, err))
}
+ s.rememberResponse(ctx, permissionOwner, &doc)
return &doc, nil
}
diff --git a/internal/docs/stream.go b/internal/docs/stream.go
index bd73b35..23639c1 100644
--- a/internal/docs/stream.go
+++ b/internal/docs/stream.go
@@ -67,7 +67,7 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
h.logger.Error("docs: stream: ResponseWriter does not flush", "sqid", doc.Sqid)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
@@ -82,7 +82,7 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
current, err := h.store.currentSeq(r.Context(), doc.ID)
if err != nil {
h.logger.Error("docs: stream: read current seq", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
diff --git a/internal/docs/team_access.go b/internal/docs/team_access.go
index 3120183..e01ed79 100644
--- a/internal/docs/team_access.go
+++ b/internal/docs/team_access.go
@@ -12,10 +12,12 @@ import (
type accessContextKey struct{}
type requestAccess struct {
- runtime *teamRuntime
- actor Actor
- subject string
- decision roster.Decision
+ responseStore *Store
+ responseDocs map[int64]permission
+ runtime *teamRuntime
+ actor Actor
+ subject string
+ decision roster.Decision
}
type permission uint8
@@ -183,3 +185,37 @@ func (s *Store) ownerMutation(ctx context.Context, owner, docID int64, mutate fu
return s.checkPermission(ctx, tx, actor, docID, permissionOwner)
})
}
+
+func (h *handlers) internalFailureStatus() int {
+ if h.store.team != nil {
+ return http.StatusServiceUnavailable
+ }
+ return http.StatusInternalServerError
+}
+func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request, item bool) bool {
+ if h.store.team == nil {
+ if h.member(r) {
+ return true
+ }
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ return false
+ }
+ actor, e := h.actor(r)
+ if e == nil {
+ e = h.store.requireMember(r.Context(), actor)
+ }
+ if e == nil {
+ return true
+ }
+ code := http.StatusServiceUnavailable
+ key := "docs.error.generic"
+ if errors.Is(e, errTeamNotMember) {
+ code = http.StatusForbidden
+ if item {
+ code = http.StatusNotFound
+ key = "docs.error.notfound"
+ }
+ }
+ http.Error(w, rastrillo.T(r, key), code)
+ return false
+}
diff --git a/internal/docs/team_emission.go b/internal/docs/team_emission.go
new file mode 100644
index 0000000..36c1ae2
--- /dev/null
+++ b/internal/docs/team_emission.go
@@ -0,0 +1,124 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo"
+ "gorm.io/gorm"
+)
+
+func (s *Store) rememberResponse(ctx context.Context, p permission, docs ...*Document) {
+ if s.team == nil {
+ return
+ }
+ a, ok := ctx.Value(accessContextKey{}).(*requestAccess)
+ if !ok || a == nil || a.runtime != s.team {
+ return
+ }
+ if a.responseDocs == nil {
+ a.responseDocs = map[int64]permission{}
+ }
+ a.responseStore = s
+ for _, doc := range docs {
+ if prior, ok := a.responseDocs[doc.ID]; !ok || p > prior {
+ a.responseDocs[doc.ID] = p
+ }
+ }
+}
+func (a *requestAccess) checkResponse(ctx context.Context) error {
+ if a.responseStore == nil {
+ return errTeamUnavailable
+ }
+ if len(a.responseDocs) == 0 && a.state() == roster.Unknown {
+ return errTeamUnavailable
+ }
+ for id, p := range a.responseDocs {
+ if e := a.responseStore.checkPermission(ctx, a.responseStore.db.WithContext(ctx), a.actor, id, p); e != nil {
+ return e
+ }
+ }
+ return nil
+}
+
+// writeGuardedBody checks each bounded chunk after rendering has completed.
+// Once headers are sent, refusal must abort the transport rather than produce
+// a successful short body with a sequence number for bytes never received.
+func writeGuardedBody(w http.ResponseWriter, r *http.Request, status int, parts ...[]byte) error {
+ a, team := r.Context().Value(accessContextKey{}).(*requestAccess)
+ if !team || a == nil {
+ if status != 0 {
+ w.WriteHeader(status)
+ }
+ for _, part := range parts {
+ if _, e := w.Write(part); e != nil {
+ return e
+ }
+ }
+ return nil
+ }
+ fail := func(e error) {
+ w.Header().Del("Docs-Seq")
+ w.Header().Del("Content-Disposition")
+ w.Header().Del("Content-Length")
+ code := http.StatusServiceUnavailable
+ key := "docs.error.generic"
+ if errors.Is(e, gorm.ErrRecordNotFound) {
+ code = http.StatusNotFound
+ key = "docs.error.notfound"
+ }
+ http.Error(w, rastrillo.T(r, key), code)
+ }
+ controller := http.NewResponseController(w)
+ prepare := func() error {
+ if e := a.checkResponse(r.Context()); e != nil {
+ return e
+ }
+ remaining := defaultStreamWriteDeadline
+ if a.state() == roster.Member {
+ lease := a.decision.Lease.Remaining()
+ if lease <= 0 {
+ return errTeamUnavailable
+ }
+ if lease < remaining {
+ remaining = lease
+ }
+ }
+ if e := controller.SetWriteDeadline(time.Now().Add(remaining)); e != nil {
+ return errTeamUnavailable
+ }
+ return nil
+ }
+ if e := prepare(); e != nil {
+ fail(e)
+ return nil
+ }
+ total := 0
+ for _, part := range parts {
+ total += len(part)
+ }
+ w.Header().Set("Content-Length", strconv.Itoa(total))
+ if status == 0 {
+ status = http.StatusOK
+ }
+ w.WriteHeader(status)
+ defer controller.SetWriteDeadline(time.Time{})
+ for _, part := range parts {
+ for len(part) > 0 {
+ if e := prepare(); e != nil {
+ panic(http.ErrAbortHandler)
+ }
+ n := min(len(part), 64<<10)
+ wrote, e := w.Write(part[:n])
+ if e != nil || wrote != n {
+ panic(http.ErrAbortHandler)
+ }
+ part = part[n:]
+ }
+ }
+ return nil
+}
diff --git a/internal/docs/team_emission_test.go b/internal/docs/team_emission_test.go
new file mode 100644
index 0000000..17f313a
--- /dev/null
+++ b/internal/docs/team_emission_test.go
@@ -0,0 +1,82 @@
+package docs
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+ "time"
+)
+
+type teamDeadlineRecorder struct {
+ *httptest.ResponseRecorder
+ afterWrite func()
+}
+
+func (w *teamDeadlineRecorder) SetWriteDeadline(time.Time) error { return nil }
+func (w *teamDeadlineRecorder) Write(b []byte) (int, error) {
+ n, e := w.ResponseRecorder.Write(b)
+ if w.afterWrite != nil {
+ f := w.afterWrite
+ w.afterWrite = nil
+ f()
+ }
+ return n, e
+}
+func TestTeamBufferedEmissionRefusesLateAndAbortsPartial(t *testing.T) {
+ for _, late := range []bool{false, true} {
+ t.Run(strconv.FormatBool(late), func(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ s := app.Store()
+ ctx := t.Context()
+ owner, e := s.EnsurePerson(ctx, "owner@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ doc, e := s.Create(ctx, owner, "owned")
+ if e != nil {
+ t.Fatal(e)
+ }
+ rt, clock, subject := testTeamRoster(t, s, app)
+ actor := localActor(owner, "owner@example.com")
+ ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject}))
+ if _, e = s.GetFor(ctx, actor, doc.Sqid); e != nil {
+ t.Fatal(e)
+ }
+ r := httptest.NewRequest("GET", "https://docs.example/d/"+doc.Sqid, nil).WithContext(ctx)
+ w := &teamDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
+ w.Header().Set("Docs-Seq", "50")
+ body := bytes.Repeat([]byte{'x'}, 3*64<<10)
+ if late {
+ clock.step(60 * time.Second)
+ } else {
+ w.afterWrite = func() { clock.step(60 * time.Second) }
+ }
+ aborted := false
+ func() {
+ defer func() {
+ if v := recover(); v != nil {
+ if v != http.ErrAbortHandler {
+ panic(v)
+ }
+ aborted = true
+ }
+ }()
+ if e = writeGuardedBody(w, r, 0, body); e != nil {
+ t.Fatal(e)
+ }
+ }()
+ if late {
+ if aborted || w.Code != 503 || w.Header().Get("Docs-Seq") != "" || bytes.Contains(w.Body.Bytes(), body[:64]) {
+ t.Fatal("late refusal emitted private bytes or sequence")
+ }
+ } else {
+ if !aborted || w.Code != 200 || w.Body.Len() != 64<<10 || w.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
+ t.Fatal("partial body was not explicitly aborted with exact length")
+ }
+ }
+ })
+ }
+}