mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
b0dc7d51e5
* feat(apps): the app API admits a person's delegated token (fresh person, membership and credential checks, the lesser of consented and offered access), attributes their writes to them, and opens delegated sign-in (TASK-3399, U5b-2) * test(apps): the app API for a person: identity, the lesser access, per-request person checks, the person's visibility, and the fresh-context re-check; the delegated author is the person (TASK-3399, U5b-2) * test(apps): a delegated upload re-admits the person before its row, and records them as the uploader (TASK-3399, U5b-2) * fix(apps): U5b-2 codex r1: every app write re-admits its actor after reading its body and before its write (one helper the upload shares); a move during the body refuses the write (TASK-3399, U5b-2) * fix(apps): U5b-2 codex r2: the app item list windows over rows the actor may see, filtered in SQL like the human list, so has_more and next_offset count nothing hidden (TASK-3399, U5b-2) * fix(apps): U5b-2 codex r3: re-admission's final step re-checks the workspace and the actor's role, not only the credential (TASK-3399, U5b-2) * test(apps): a delegated app write's event names the install (U10d) and credits the person, never the bot (TASK-3399, U5b-2 x U10d)
288 lines
10 KiB
Go
288 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/appstore"
|
|
"github.com/PerpetualSoftware/pad/internal/attachments"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// App attachments over the app API (TASK-3401 U6c, DOC-3371 §4). The store
|
|
// side, the upload sequence and the read rule are appstore's (U7a); these
|
|
// handlers add the request shape, the write gate, the visibility and
|
|
// re-check registration, and serving.
|
|
|
|
// appAttachmentRouteDeadline bounds an upload or a download's work. It is not
|
|
// a correctness proof (DOC-3371 §4): a download that passed its gate streams
|
|
// for at most this long after it, which is the one place an app read is
|
|
// ordered against revocation only up to its first byte.
|
|
var appAttachmentRouteDeadline = 5 * time.Minute
|
|
|
|
func (s *Server) appUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
|
ac := appContextFrom(r)
|
|
// Size first (DOC-3371 §4 step 1), before any lookup: a declared length
|
|
// is required, so a chunked or unsized body is refused.
|
|
if r.ContentLength < 0 || len(r.TransferEncoding) > 0 {
|
|
writeError(w, http.StatusLengthRequired, "length_required", "Content-Length is required")
|
|
return
|
|
}
|
|
if r.ContentLength == 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "A non-empty body is required")
|
|
return
|
|
}
|
|
if r.ContentLength > appstore.AppAttachmentMaxFileBytes {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "The file exceeds the 25 MiB upload limit")
|
|
return
|
|
}
|
|
// The write gate is an app field edit's: the item visible under the
|
|
// ceiling, a companion the app may write, and the subject's edit right.
|
|
// That the item was created by this install is the FencedTx's check
|
|
// (requireCreatedHere), at admission and again at the row.
|
|
item, _, ok := s.appVisibleItem(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !s.requireEditPermission(w, r, ac.WorkspaceID, item.ID, item.CollectionID) {
|
|
return
|
|
}
|
|
as, err := s.appStore()
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), appAttachmentRouteDeadline)
|
|
defer cancel()
|
|
itemID, collectionID := item.ID, item.CollectionID
|
|
pending, err := as.StageUpload(ctx, ac.appFenceSpec(), item.ID, appstore.AppUpload{
|
|
Body: r.Body, DeclaredSize: r.ContentLength, Filename: r.URL.Query().Get("filename"),
|
|
}, appActor(ac))
|
|
if err != nil {
|
|
writeAppStoreError(w, err)
|
|
return
|
|
}
|
|
defer pending.Close()
|
|
// The subject's part of the write gate again, once the body is in (codex
|
|
// U6c r3): a body can take minutes. A full re-admission (the credential,
|
|
// the install, the membership and its role as they stand now, and the item
|
|
// re-check appVisibleItem registered, replayed under the fresh context),
|
|
// then the edit right, which the unchanged role keeps current. The fence
|
|
// checks the companion and the creator in the row's own transaction; what
|
|
// remains is the gap between this check and that transaction, the one an
|
|
// item write has.
|
|
if !s.appReadmitBeforeWrite(w, r, itemID, collectionID) {
|
|
return
|
|
}
|
|
att, err := pending.Insert(ctx)
|
|
if err != nil {
|
|
writeAppStoreError(w, err)
|
|
return
|
|
}
|
|
// The DTO is the row the upload's own transaction inserted. An upload
|
|
// writes no item row and, like the human upload, publishes no event.
|
|
writeAppJSON(w, http.StatusCreated, att)
|
|
}
|
|
|
|
// appVisibleAttachment authorizes one attachment for a read: the app read
|
|
// rule (a live row bound to a live item in a companion, the FencedTx's
|
|
// check), the bound item visible to the subject, and re-checks of both
|
|
// registered for the response's re-admission. Every refusal is the same
|
|
// attachment 404.
|
|
func (s *Server) appVisibleAttachment(w http.ResponseWriter, r *http.Request, att *appstore.AppAttachment) bool {
|
|
ac := appContextFrom(r)
|
|
it, err := s.store.GetItem(att.ItemID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if it == nil || it.WorkspaceID != ac.WorkspaceID || it.DeletedAt != nil {
|
|
writeAppNotFound(w, "Attachment")
|
|
return false
|
|
}
|
|
visible, err := s.checkItemVisible(ac.WorkspaceID, it, currentUser(r), workspaceRole(r), isBearerAuth(r))
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if !visible {
|
|
writeAppNotFound(w, "Attachment")
|
|
return false
|
|
}
|
|
id, itemID, coll := att.ID, it.ID, it.CollectionID
|
|
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckItem(r2, itemID, coll) })
|
|
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckAttachment(r2, id, itemID) })
|
|
return true
|
|
}
|
|
|
|
// appRecheckAttachment replays the read rule under the re-admitted context:
|
|
// the attachment must still be visible to the app and still bound to the
|
|
// same item.
|
|
func (s *Server) appRecheckAttachment(r *http.Request, id, itemID string) error {
|
|
as, err := s.appStore()
|
|
if err != nil {
|
|
return appFault(err)
|
|
}
|
|
att, err := as.GetAttachment(r.Context(), appContextFrom(r).appFenceSpec(), id)
|
|
switch {
|
|
case errors.Is(err, store.ErrAppAttachmentNotFound), errors.Is(err, store.ErrFenceStale),
|
|
errors.Is(err, store.ErrNotCompanion), errors.Is(err, store.ErrFenceClosed):
|
|
return errAppRecheck
|
|
case err != nil:
|
|
return appFault(err)
|
|
}
|
|
if att.ItemID != itemID {
|
|
return errors.New("the attachment moved")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) appGetAttachment(w http.ResponseWriter, r *http.Request) {
|
|
ac := appContextFrom(r)
|
|
as, err := s.appStore()
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
att, err := as.GetAttachment(r.Context(), ac.appFenceSpec(), chi.URLParam(r, "attachmentID"))
|
|
if err != nil {
|
|
writeAppStoreError(w, err)
|
|
return
|
|
}
|
|
if !s.appVisibleAttachment(w, r, att) {
|
|
return
|
|
}
|
|
writeAppJSON(w, http.StatusOK, att)
|
|
}
|
|
|
|
// appDownloadAttachment streams an attachment's bytes, or a variant's. It is
|
|
// NOT buffered (lead ruling, U6c): it authorizes, opens the blob, and then
|
|
// re-admits the whole request (appRevalidate, every registered re-check)
|
|
// immediately before the first byte. A revocation committed before that
|
|
// point sends nothing. One committed after it can still see the rest of
|
|
// this stream, bounded by appAttachmentRouteDeadline: the one place an app
|
|
// read is ordered against revocation only up to its first byte, stated in
|
|
// DOC-3371.
|
|
func (s *Server) appDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
|
if s.appStreamDone != nil {
|
|
defer s.appStreamDone()
|
|
}
|
|
ac := appContextFrom(r)
|
|
as, err := s.appStore()
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
deadline := time.Now().Add(appAttachmentRouteDeadline)
|
|
ctx, cancel := context.WithDeadline(r.Context(), deadline)
|
|
defer cancel()
|
|
id := chi.URLParam(r, "attachmentID")
|
|
// An unknown variant name is a malformed request, as on the regular
|
|
// download, not a silent fallback to the original (codex U6c r2).
|
|
variant := r.URL.Query().Get("variant")
|
|
if variant != "" && !isKnownVariant(variant) {
|
|
writeError(w, http.StatusBadRequest, "bad_variant", "Unknown variant")
|
|
return
|
|
}
|
|
// Authorize BEFORE touching storage (codex U6c r1 P2): a storage error
|
|
// (a missing blob, a missing variant blob) on an attachment the subject
|
|
// may not see would answer differently from an unknown id.
|
|
meta, err := as.GetAttachment(ctx, ac.appFenceSpec(), id)
|
|
if err != nil {
|
|
writeAppStoreError(w, err)
|
|
return
|
|
}
|
|
if !s.appVisibleAttachment(w, r, meta) {
|
|
return
|
|
}
|
|
att, f, err := as.OpenAttachment(ctx, ac.appFenceSpec(), id, variant)
|
|
if err != nil {
|
|
writeAppStoreError(w, err)
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
// The open re-read the row; what it opened must be the same attachment's
|
|
// (or its variant's) on the item just authorized.
|
|
if att.ItemID != meta.ItemID {
|
|
writeAppNotFound(w, "Attachment")
|
|
return
|
|
}
|
|
// A variant is its own row: the gate re-checks the row whose bytes are
|
|
// served, not only the original that was authorized (codex U6c r6).
|
|
if att.ID != meta.ID {
|
|
served, item := att.ID, meta.ItemID
|
|
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckAttachment(r2, served, item) })
|
|
}
|
|
if s.appBeforeFirstByte != nil {
|
|
s.appBeforeFirstByte()
|
|
}
|
|
// THE GATE: nothing has been written yet.
|
|
if err := s.appRevalidate(r); err != nil {
|
|
writeAppRevalidateError(w, err)
|
|
return
|
|
}
|
|
// The deadline also bounds the transport (codex U6c r1 P2): a context
|
|
// stops only the next file read, so a client that stops reading would
|
|
// otherwise hold a blocked write open past it and receive more bytes
|
|
// later. A ResponseWriter without deadline support (a test recorder) is
|
|
// served with the read-side bound alone.
|
|
if err := http.NewResponseController(w).SetWriteDeadline(deadline); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
contentType, disposition := attachmentServeType(att.MimeType)
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition",
|
|
contentDisposition(disposition, sanitizeHeaderFilename(attachments.ServedFilename(att.Filename))))
|
|
http.ServeContent(w, r, att.Filename, att.CreatedAt, ctxReadSeeker{ctx: ctx, f: f})
|
|
}
|
|
|
|
// ctxReadSeeker stops a stream when its context ends: the route deadline, or
|
|
// the client going away. ServeContent then ends the response.
|
|
type ctxReadSeeker struct {
|
|
ctx context.Context
|
|
f *os.File
|
|
}
|
|
|
|
func (c ctxReadSeeker) Read(p []byte) (int, error) {
|
|
if err := c.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return c.f.Read(p)
|
|
}
|
|
|
|
func (c ctxReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
|
return c.f.Seek(offset, whence)
|
|
}
|
|
|
|
// writeAppAttachmentError maps the attachment-specific store errors; the
|
|
// rest go to writeAppStoreError.
|
|
func writeAppAttachmentError(w http.ResponseWriter, err error) bool {
|
|
switch {
|
|
case errors.Is(err, store.ErrAppAttachmentNotFound):
|
|
writeAppNotFound(w, "Attachment")
|
|
case errors.Is(err, store.ErrAppAttachmentLimit):
|
|
// No totals (DOC-3371 §4).
|
|
writeError(w, http.StatusForbidden, "attachment_limit_reached", "The app has reached its attachment limit")
|
|
case errors.Is(err, appstore.ErrAppSizeMismatch), errors.Is(err, io.ErrUnexpectedEOF):
|
|
// A body that ends before its declared length: over a connection
|
|
// the request body reports io.ErrUnexpectedEOF (codex U6c r2), the
|
|
// same client mistake as a short body.
|
|
writeError(w, http.StatusBadRequest, "size_mismatch", "The body does not match Content-Length")
|
|
case errors.Is(err, appstore.ErrAppNoAttachmentStore):
|
|
writeError(w, http.StatusServiceUnavailable, "attachments_unavailable", "Attachment storage is not available")
|
|
case errors.Is(err, attachments.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "blob_missing", "The attachment's file is missing")
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|