Skip to content

feat: import a .docx as a new document in one call - #45

Merged
Robertzu43 merged 12 commits into
mainfrom
feat/import-document-as-new
Aug 24, 2026
Merged

feat: import a .docx as a new document in one call#45
Robertzu43 merged 12 commits into
mainfrom
feat/import-document-as-new

Conversation

@Robertzu43

@Robertzu43 Robertzu43 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What and why

Getting a .docx in as a new document took two calls and a name typed from memory. POST /api/v1/documents accepts JSON — a name and an optional folder, no file. POST /api/v1/documents/{id}/versions (and its :import sibling) attaches a version to a document that must
already exist. So: create an empty document with a hand-typed name, then upload into it — and if the
upload leg failed, an empty document was left in the library for someone to trash by hand.

This adds POST /api/v1/documents:import — multipart, one call, document + first version — and an
Import document control on the dashboard that uses it.

Field Required Meaning
file yes the document bytes
name no omitted → derived from the filename minus its extension
folderId no must exist in the caller's org, same check Create runs

Returns 201 with { id, name, folderId, versionId, major, minor, revision } — both halves of what was
created, so a client needs no follow-up read.

Worth stating what was already there, since it is most of what "rename on import" could mean and none
of it was the gap: PATCH /documents/{id} renames a document, PATCH /versions/{vid} renames a
version, and POST /versions/{vid}/copies {name} already names a child document at fork time with
a "Copy name" field in the UI. The missing capability was specifically file in, new document out, in
one step
.

Design notes, both in this PR:
docs/superpowers/specs/2026-08-22-import-document-as-new-design.md and
docs/superpowers/plans/2026-08-22-import-document-as-new.md.

Also fixes a pre-existing 500 (27924e8)

POST /api/v1/documents/{id}/versions answered an unterminated multipart body with a 500.
SaveAsync is the one place in the product written to survive a hostile multipart body, but it caught
only InvalidDataException — a bad Content-Disposition — while a body that never reaches its closing
boundary raises IOException from the body reader instead.

Found while hardening documents:import, which funnels the same bodies through the same block. The fix
went in the shared path rather than in the one caller that noticed, so both routes answer client
garbage with problem+json. Regression-tested: reverting the catch gives InternalServerError,
restoring it gives BadRequest.

The parts worth reviewing

Route mapped on app, not on the documents group. RouteGroupBuilder joins its prefix to a
pattern with a / unless the pattern is empty, so g.MapPost(":import") routes
/api/v1/documents/:import. A collection-level colon action has to spell out the whole path and
re-apply the three things group membership was giving it. Verified rather than assumed — the route test
went 404 → 401 across that one commit.

Validation runs before the first write, so a rejected request cannot create a document — which is
the entire point of the endpoint. Pinned by asserting a brand-new account's document list is empty
after a rejected body and after a whitespace-only name, rather than arguing it from reading the code.

Not a single transaction, deliberately. CommitSaveAsync opens its own transaction and takes
SELECT … FOR UPDATE on the document row for the counter increment (spec §5.1), so it cannot enlist in
an outer one. This endpoint therefore has Fork's shape: document + branch + owner membership + audit
in one SaveChanges, then CommitSaveAsync. So the honest guarantee is narrower than atomic — a
client can no longer strand an empty document (no browser-closed-between-calls orphan), and the
residual window is a server-side failure between the two steps, exactly the window Fork has today.
Closing it means changing CommitSaveAsync's locking for every write path in the product. Recorded in a
ponytail: comment naming Fork as the precedent.

Mime is sniffed from the stored bytes, never read from file.ContentType or file.FileName. That
matters more here than on upload, because the filename is now also where the document's name comes
from — one attacker-controlled string reaching two places.

The name derivation splits on both / and \ rather than using
Path.GetFileNameWithoutExtension: on Linux Path does not treat \ as a separator, so a
Windows-style path would become a document literally called C:\docs\lease. A courtesy for display,
not a security measure — the value is stored as a name and never used as a path.

The client's prefill mirrors the server's derivation exactly (09e9276). It briefly did not: `dot

0prefilled the literal".docx"for a file named just.docx`, which is a non-empty name,
therefore sent, therefore accepted — so the UI would have created the very document the endpoint 400s
to refuse.

A typed name is never clobbered by a later file pick. The last derived value is kept in state so
"the field still shows what a pick put there" is distinguishable from "the user typed this on purpose"
— including the case where someone types a name, realises they picked the wrong file, and swaps it.

How to verify

dotnet build easydocs.slnx          # zero warnings (TreatWarningsAsErrors)
dotnet test                         # needs Docker (Testcontainers)
npm --prefix web run build && npm --prefix web run lint
docker compose up -d --build        # --build matters: a cached image serves the old backend
npm --prefix web run e2e

Then on the dashboard: Import document → pick a .docx → the name fills in from the file → Import
lands you on the new document's console at 0.0.1.

Failing-then-passing evidence — each of these was confirmed by reverting the change and watching the
test go red, not by inspection:

reverted what failed
the IOException in SaveAsync's catch An_unterminated_multipart_body_is_a_400_and_never_a_500Actual: InternalServerError
the name helper's >= 0 the all-extension case → Expected: BadRequest / Actual: Created
the dashboard's name prefill e2e → Expected: "edited-plus-echo" / Received: ""
sending the derived name instead of the typed one e2e → the heading "Renamed On The Way In" never appears

That last spec asserts the created document's rendered name, not the input's value, on purpose: an
input holding the right string proves nothing about what was sent, and that exact trap has bitten this
repo twice.

Checklist

  • Every commit is signed off
  • dotnet build easydocs.slnx produces zero warnings.
  • dotnet test passes — 418 passed. Only the two soffice-guarded PDF tests skip locally; CI
    installs LibreOffice and skips zero. No conformance criterion skips.
  • New behaviour has a test, and the bug fix has a regression test that fails without the fix.
  • Web UI change? build succeeds, e2e passes (98 specs). oxlint prints two
    react(only-export-components) warnings, both pre-existing in src/auth.tsx and
    src/components/FolderTree.tsx, neither touched by this PR — so "clean" means unchanged, not
    silent.
  • Docs updated — docs-site/docs/user-guide.md, the regenerated OpenAPI snapshot at
    docs-site/docs/api/openapi/v1.json (which generates the API reference), and CHANGELOG.md under
    [Unreleased].
  • I read CONTRIBUTING.md and agree to the Code of Conduct.

Scope

  • This PR adds or changes a database migration.
  • This PR adds or changes a public API endpoint. POST /api/v1/documents:import is new.
    Spec §10.1 is updated in this PR, so the spec and the code do not drift.
  • This PR changes a security property listed in SECURITY.md. (The SaveAsync fix turns a 500
    into a 400 on malformed client input — hardening, not a change to anything SECURITY.md lists.)
  • None of the above.

Addendum after review

Three independent reviewers went over this; the third found things the first two did not. Changes since
the description above was written:

  • DirectoryNotFoundException is excluded from the multipart catch. The previous comment claimed a
    truncated body and a broken temp dir were indistinguishable — that was false for this type, and it
    named the one case that could have been separated. Catching it turned a server misconfiguration into a
    400 blaming the uploader, on the pre-existing upload route as well. Verified by forcing
    ASPNETCORE_TEMP at an unusable path: 500 on both routes for a 512 KB body, 400 still for a
    truncated one.
  • The 413 rethrow is now pinned in .github/scripts/conformance-smoke.sh against the shipped compose
    image, on both routes. It cannot be tested in-process: TestServer does not enforce
    MaxRequestBodySize, so a 40 MB body returns 201 under the xUnit fixture.
  • Import is disabled until a file is chosen. Clicking it empty was a silent no-op — the one write on
    the dashboard that dodged act(), whose own comment calls that "the worst outcome available". Spec
    proven to fail when the attribute is removed.
  • Fork carries a ponytail: marker for its identical orphan exposure (measured at 54 in 93 aborted
    forks), so the debt is discoverable from the call site instead of from a comment in another file.
  • Corrections: a comment pointed at a symbol name that does not exist; the docs said "Import a
    document" where the button says "Import document"; the design doc had drifted from its own branch on
    both the exception handling and the cancellation decision; and the evidence row above had its direction
    inverted.

Known and deliberate: no in-process test on either the cancellation fix or the 413 rethrow — both
need synthetic throwing code in src/, which ProblemDetailsTests already rules out for this exact
exception type. The comments carry the measurements and say so. A pre-existing NUL-byte 500 (also on
POST /documents, PATCH /documents/{id}, POST /folders) and Fork's orphan are their own PRs.

🤖 Generated with Claude Code

Robertzu43 and others added 9 commits August 22, 2026 09:55
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Mapped on app rather than the documents group: RouteGroupBuilder joins its
prefix to a pattern with a slash, so a collection-level colon action cannot
be built from the group prefix. Handler lands next.

Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Spec 10.1 is the authoritative endpoint set, so POST /documents:import moves
in the same change as the code. The API reference needs no edit -- it is
generated from the OpenAPI snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
POST /api/v1/documents:import takes a file, an optional name and an optional
folderId, and returns the new document with its first version. The name
falls back to the filename minus its extension; a filename with no usable
stem is a 400 rather than a document nobody named.

Body handling is SaveAsync's, so a hostile or malformed multipart body is a
400 rather than a 500, and the blob's mime is sniffed from the stored bytes
rather than read from the attacker-controlled filename.

Signed-off-by: Robertzu43 <robertzu43@icloud.com>
An unterminated multipart body raises IOException from the body reader, not
the InvalidDataException a bad Content-Disposition raises, so SaveAsync
caught only half of what it was written to catch and answered client garbage
with a 500 on a public endpoint.

Found while hardening documents:import, which funnels the same bodies
through the same block -- so the fix belongs in the shared path rather than
in the one caller that noticed. Regression-tested: reverting the catch
returns InternalServerError.

Also simplifies the import name helper. The plan's dot > 0 did not do what
its own prose claimed -- a filename of just ".docx" kept its dot and came
back 201 -- and >= 0 plus the existing empty-after-trim check is the same
behaviour in one branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
One disclosure, one call. The name is prefilled from the filename but never
overwrites a name the user typed -- picking a second file only re-derives it
if the field still holds the first file's derived value.

Signed-off-by: Robertzu43 <robertzu43@icloud.com>
…e button

stemOf used dot > 0, so a file called just ".docx" prefilled the literal
".docx" -- a non-empty name, therefore sent, therefore accepted. The UI
would have created exactly the document the endpoint 400s to refuse. >= 0
yields an empty prefill, no name is sent, and the server answers.

The picker also sits inside a .stack form, and .stack label is (0,1,1) to
.filebutton's (0,1,0), so the cascade rendered the form's primary control as
one of its uppercase muted field labels. Scoped to the child combinator, so
the tile's picker is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Copilot AI lite review requested due to automatic review settings August 22, 2026 15:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a one-call “import as new document” flow to EasyDocs by introducing a new multipart endpoint (POST /api/v1/documents:import) and wiring it into the dashboard UI, with accompanying hardening/tests/docs so failed imports don’t strand empty documents.

Changes:

  • Backend: add POST /api/v1/documents:import to create a document + first committed version from one multipart request, plus shared multipart hardening (IOException catch) and new API tests.
  • Web: add an “Import document” dashboard disclosure (file picker + name prefill/editing) and e2e coverage; add an ImportedDocument response type.
  • Docs: update design/spec/plan docs, user guide, OpenAPI snapshot, and changelog to reflect the new endpoint and UI.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
web/src/routes/Dashboard.tsx Adds “Import document” UI, filename→name prefill logic, and navigation after successful import.
web/src/index.css Adjusts styling specificity so .filebutton inside .stack renders like a control rather than a muted label.
web/src/api.ts Introduces ImportedDocument response type for the new import endpoint.
web/e2e/dashboard.spec.ts Adds Playwright coverage for import prefill and edited-name-on-import behavior.
tests/EasyDocs.Api.Tests/DocumentUploadTests.cs Adds regression test ensuring unterminated multipart bodies return 400 (problem+json), not 500.
tests/EasyDocs.Api.Tests/DocumentImportTests.cs New test suite covering endpoint routing/auth, naming rules, folder validation, malformed bodies, membership, linkage, and auditing.
src/EasyDocs.Api/Documents/DocumentEndpoints.cs Implements ImportNew endpoint, filename→name helper, and broadens multipart parsing catch to include IOException.
docs/superpowers/specs/2026-08-22-import-document-as-new-design.md New design spec documenting endpoint/UI behavior and rationale.
docs/superpowers/specs/2026-07-24-easydocs-v1-design.md Updates the authoritative endpoint list to include POST /documents:import.
docs/superpowers/plans/2026-08-22-import-document-as-new.md New implementation plan outlining steps/tests/conventions.
docs-site/docs/user-guide.md Adds dashboard user-facing guidance for one-step import and editable derived name.
docs-site/docs/api/openapi/v1.json Updates OpenAPI snapshot to include /api/v1/documents:import.
CHANGELOG.md Documents the new one-step import capability and the dashboard control under “Added”.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +191 to +209
onSubmit={(e) => {
e.preventDefault()
if (!importFile) return
const body = new FormData()
body.append('file', importFile)
// Omitted rather than sent empty: an empty string is still a name to the API, and
// the endpoint's own filename-derived default (spec-required for a bare import) is
// better than this form re-deriving it a second time and risking a mismatch.
if (importName.trim()) body.append('name', importName.trim())
if (folderId) body.append('folderId', folderId)
void act(() =>
api.post<ImportedDocument>('/api/v1/documents:import', body).then((doc) => {
setImportFile(null)
setImportName('')
setDerivedImportName('')
navigate(`/documents/${doc.id}`)
}),
)
}}
…p eating Kestrel's 413

Two defects found in review, both measured.

The import passed ctx.RequestAborted to BOTH writes, so a client that hung up
between them cancelled the version and left the document behind -- the exact
orphan the endpoint exists to prevent, arriving by a different door. Measured
with a probe that sends the whole body then RSTs after 0.5-30ms: 23 orphans in
~191 aborted imports (~12%) before, 0 in ~199 and 0 in ~192 after. The probe
was validated by reverting the token and watching the orphans reappear, so the
zero means the fix works rather than the probe missing.

Separately, BadHttpRequestException derives from IOException, so widening the
multipart catch swallowed Kestrel's body-size violation: a 40MB upload turned
from 413 "Request body too large ... 30000000 bytes" into 400 "The multipart
body could not be parsed", on the pre-existing upload route as well as the new
one. Rethrown ahead of the broad clause; Program.cs's handler already renders
it as problem+json from the exception's own StatusCode. Verified by request on
both routes: 413 with the limit named, while a truncated body still gives 400.

The broad clause now logs, because the remaining types are not separable -- a
truncated body from a hostile client and a form-buffering temp-dir failure both
arrive as a plain IOException, so 400 is right for one and wrong for the other,
and the wrong one previously failed every real upload silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Copilot AI review requested due to automatic review settings August 22, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

web/src/routes/Dashboard.tsx:207

  • act() always calls load(null) after a successful mutation. In the import flow you navigate away inside the same promise chain, so this still triggers an extra /api/v1/documents load and state updates right as the Dashboard unmounts. Consider bypassing act() here (manual try/catch + setError) or extending act with an option to skip the reload for mutations that immediately navigate.
                    if (importName.trim()) body.append('name', importName.trim())
                    if (folderId) body.append('folderId', folderId)
                    void act(() =>
                      api.post<ImportedDocument>('/api/v1/documents:import', body).then((doc) => {
                        setImportFile(null)
                        setImportName('')
                        setDerivedImportName('')
                        navigate(`/documents/${doc.id}`)
                      }),

…Fork

The ponytail note said the residual window was accepted "matching Fork",
while the paragraph below it explained the client half is now closed -- so the
two said different things about whether the handlers agree. They deliberately
do not: Fork still passes ctx.RequestAborted and is a known follow-up.

Names that explicitly, because the failure mode is specific and predicted: a
future reader "cleaning up" CancellationToken.None back to ctx.RequestAborted
for consistency with Fork would silently restore the orphan. There is no
in-process test on that line -- reaching the window deterministically needs
synthetic throwing code in src/, which ProblemDetailsTests already rules out --
so the comment carries the measurement and says so.

Also names the ingest log category once. Two copies of a literal drift, and a
category that stops matching its sibling makes an operator's grep miss half the
evidence; WopiEndpoints funnels its own literal through one helper for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Copilot AI review requested due to automatic review settings August 22, 2026 16:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

web/src/routes/Dashboard.tsx:207

  • act() always calls load(null) after the mutation. In this submit handler, the mutation callback calls navigate(...), which will unmount Dashboard and can cause load() to run/set state on an unmounted component (React warns about state updates after unmount).

Consider keeping navigation outside act() (e.g., do a local try/catch without reloading, or refactor act to optionally skip the reload / return the mutation result so you can await act(...) and then navigate).

                    void act(() =>
                      api.post<ImportedDocument>('/api/v1/documents:import', body).then((doc) => {
                        setImportFile(null)
                        setImportName('')
                        setDerivedImportName('')
                        navigate(`/documents/${doc.id}`)
                      }),

…ilently

Third review found the previous comment's own example was false.
DirectoryNotFoundException IS separable from a bare IOException, so it is now
excluded from the broad multipart catch: form buffering spills parts over 64KB
to a temp file, and an unusable temp dir is the server's misconfiguration, not
the caller's body. Catching it meant every real upload on BOTH ingest routes
returned 400 blaming the uploader while anything alerting on 5xx saw a healthy
service. Verified by forcing ASPNETCORE_TEMP at an unusable path: 500 on both
routes for a 512KB body, while a truncated body still returns 400.

What remains genuinely ambiguous -- a truncated body versus a full disk, both
bare IOException -- still answers 400 and is logged at Warning rather than
Error, because hostile bodies are routine traffic on a public endpoint and
Error there would drown the signal.

The 413 rethrow now has a real guard. It cannot be tested in-process (the limit
is Kestrel's and TestServer does not enforce MaxRequestBodySize -- a 40MB body
returns 201 under the fixture), so it is asserted in conformance-smoke.sh
against the shipped compose image, on both routes. Ran the whole script: passes.

Clicking Import with no file chosen was a silent no-op -- the one write on the
dashboard that dodged act(), whose own comment calls that "the worst outcome
available". Now disabled until a file is chosen, with a spec proven to fail
when the attribute is removed.

Also: Fork carries a ponytail: marker for its identical orphan exposure (54 in
93 aborted forks) so the debt is discoverable from the call site rather than
only from a comment in another file; a comment pointing at
DeriveNameFromFileName named a symbol that does not exist; the docs said
"Import a document" where the button says "Import document"; and the design
doc had drifted from its own branch on both the exception handling and the
cancellation decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Robertzu43 <robertzu43@icloud.com>
Copilot AI review requested due to automatic review settings August 22, 2026 16:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (1)

web/src/routes/Dashboard.tsx:208

  • The import flow is wrapped in act(), which always calls load(null) after the mutation. Since the success path navigates away (navigate(/documents/${doc.id})), this triggers an extra documents-list fetch and state updates after route change/unmount. Consider bypassing act() for this case (or adding an option to skip the post-mutation reload) and handling errors locally so navigation doesn’t cause an unnecessary reload.
                  onSubmit={(e) => {
                    e.preventDefault()
                    if (!importFile) return
                    const body = new FormData()
                    body.append('file', importFile)
                    // Omitted rather than sent empty: an empty string is still a name to the API, and
                    // the endpoint's own filename-derived default (spec-required for a bare import) is
                    // better than this form re-deriving it a second time and risking a mismatch.
                    if (importName.trim()) body.append('name', importName.trim())
                    if (folderId) body.append('folderId', folderId)
                    void act(() =>
                      api.post<ImportedDocument>('/api/v1/documents:import', body).then((doc) => {
                        setImportFile(null)
                        setImportName('')
                        setDerivedImportName('')
                        navigate(`/documents/${doc.id}`)
                      }),
                    )

@Robertzu43
Robertzu43 merged commit e8127f1 into main Aug 24, 2026
7 checks passed
@Robertzu43
Robertzu43 deleted the feat/import-document-as-new branch August 24, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants