Guard buffered TeamMode responses and retryable handler failures
12 files changed,
+314
−73
internal/docs/export.go+5 −5internal/docs/grants.go+2 −0internal/docs/handlers.go+36 −34internal/docs/publish.go+6 −7internal/docs/render.go+1 −5internal/docs/search.go+4 −4internal/docs/share.go+10 −12internal/docs/store.go+2 −0internal/docs/stream.go+2 −2internal/docs/team_access.go+40 −4internal/docs/team_emission.go+124 −0internal/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") |
| + } |
| + } |
| + }) |
| + } |
| +} |