feat: import a .docx as a new document in one call - #45
Conversation
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>
There was a problem hiding this comment.
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:importto create a document + first committed version from one multipart request, plus shared multipart hardening (IOExceptioncatch) and new API tests. - Web: add an “Import document” dashboard disclosure (file picker + name prefill/editing) and e2e coverage; add an
ImportedDocumentresponse 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.
| 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>
There was a problem hiding this comment.
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 callsload(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/documentsload and state updates right as the Dashboard unmounts. Consider bypassingact()here (manual try/catch + setError) or extendingactwith 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>
There was a problem hiding this comment.
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 callsload(null)after the mutation. In this submit handler, the mutation callback callsnavigate(...), which will unmountDashboardand can causeload()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>
There was a problem hiding this comment.
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 callsload(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 bypassingact()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}`)
}),
)
What and why
Getting a
.docxin as a new document took two calls and a name typed from memory.POST /api/v1/documentsaccepts JSON — a name and an optional folder, no file.POST /api/v1/documents/{id}/versions(and its:importsibling) attaches a version to a document that mustalready 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 anImport document control on the dashboard that uses it.
filenamefolderIdCreaterunsReturns
201with{ id, name, folderId, versionId, major, minor, revision }— both halves of what wascreated, 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 aversion, and
POST /versions/{vid}/copies{name}already names a child document at fork time witha "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.mdanddocs/superpowers/plans/2026-08-22-import-document-as-new.md.Also fixes a pre-existing 500 (
27924e8)POST /api/v1/documents/{id}/versionsanswered an unterminated multipart body with a 500.SaveAsyncis the one place in the product written to survive a hostile multipart body, but it caughtonly
InvalidDataException— a badContent-Disposition— while a body that never reaches its closingboundary raises
IOExceptionfrom the body reader instead.Found while hardening
documents:import, which funnels the same bodies through the same block. The fixwent 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.RouteGroupBuilderjoins its prefix to apattern with a
/unless the pattern is empty, sog.MapPost(":import")routes/api/v1/documents/:import. A collection-level colon action has to spell out the whole path andre-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.
CommitSaveAsyncopens its own transaction and takesSELECT … FOR UPDATEon the document row for the counter increment (spec §5.1), so it cannot enlist inan outer one. This endpoint therefore has
Fork's shape: document + branch + owner membership + auditin one
SaveChanges, thenCommitSaveAsync. So the honest guarantee is narrower than atomic — aclient 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
Forkhas today.Closing it means changing
CommitSaveAsync's locking for every write path in the product. Recorded in aponytail:comment namingForkas the precedent.Mime is sniffed from the stored bytes, never read from
file.ContentTypeorfile.FileName. Thatmatters 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 usingPath.GetFileNameWithoutExtension: on LinuxPathdoes not treat\as a separator, so aWindows-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: `dotA 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
Then on the dashboard: Import document → pick a
.docx→ the name fills in from the file → Importlands 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:
IOExceptioninSaveAsync's catchAn_unterminated_multipart_body_is_a_400_and_never_a_500→Actual: InternalServerError>= 0Expected: BadRequest / Actual: CreatedExpected: "edited-plus-echo" / Received: """Renamed On The Way In"never appearsThat 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
dotnet build easydocs.slnxproduces zero warnings.dotnet testpasses — 418 passed. Only the twosoffice-guarded PDF tests skip locally; CIinstalls LibreOffice and skips zero. No conformance criterion skips.
buildsucceeds,e2epasses (98 specs).oxlintprints tworeact(only-export-components)warnings, both pre-existing insrc/auth.tsxandsrc/components/FolderTree.tsx, neither touched by this PR — so "clean" means unchanged, notsilent.
docs-site/docs/user-guide.md, the regenerated OpenAPI snapshot atdocs-site/docs/api/openapi/v1.json(which generates the API reference), andCHANGELOG.mdunder[Unreleased].Scope
POST /api/v1/documents:importis new.Spec §10.1 is updated in this PR, so the spec and the code do not drift.
SaveAsyncfix turns a 500into a 400 on malformed client input — hardening, not a change to anything SECURITY.md lists.)
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:
DirectoryNotFoundExceptionis excluded from the multipart catch. The previous comment claimed atruncated 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
400blaming the uploader, on the pre-existing upload route as well. Verified by forcingASPNETCORE_TEMPat an unusable path:500on both routes for a 512 KB body,400still for atruncated one.
.github/scripts/conformance-smoke.shagainst the shipped composeimage, on both routes. It cannot be tested in-process:
TestServerdoes not enforceMaxRequestBodySize, so a 40 MB body returns201under the xUnit fixture.the dashboard that dodged
act(), whose own comment calls that "the worst outcome available". Specproven to fail when the attribute is removed.
Forkcarries aponytail:marker for its identical orphan exposure (measured at 54 in 93 abortedforks), so the debt is discoverable from the call site instead of from a comment in another file.
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/, whichProblemDetailsTestsalready rules out for this exactexception type. The comments carry the measurements and say so. A pre-existing NUL-byte
500(also onPOST /documents,PATCH /documents/{id},POST /folders) andFork's orphan are their own PRs.🤖 Generated with Claude Code