@codefusion-cc/attachments
v0.1.2
Published
Files people attach: paste, drop and picker in the page with a policy of types and sizes, uploads with progress and retry, and on the Worker each upload streamed into R2 once its bytes show a type the policy takes, served back so no browser runs anything
Maintainers
Readme
@codefusion-cc/attachments
Files people attach to something they send: a screenshot pasted into a form, a log dropped on it, a video picked from
the phone. In the page it collects them from paste, drop and a file picker, checks each against the app's policy of
types and sizes, and uploads it at once with progress, retry and a preview. On the Worker it streams each upload into
R2 once its first bytes show a type the policy takes, and serves it back so that no browser runs anything in it. No
dependencies; React is an optional peer for ./react.
npm install @codefusion-cc/attachmentsThe policy
One policy, shared by the page and the Worker, says which types an app takes and how large:
import { attachmentPolicy } from '@codefusion-cc/attachments' // or '/browser'
export const POLICY = attachmentPolicy({ image: 20e6, video: 95e6, pdf: 20e6, text: 2e6 })- Groups:
image(PNG, JPEG, GIF, WebP, AVIF, HEIC),video(MP4, QuickTime, WebM),pdf,text(plain, Markdown, CSV, JSON). A type named on its own ('image/gif': 5e6) wins over its group. A type not in the policy is refused. - Never SVG, HTML or anything else a browser could run: the package knows only types it can verify from their bytes.
acceptAttribute(policy)is the file input'saccept;checkAttachment(policy, file)says before any upload whether a file is taken ({ ok: true, type }) or why not:unsupported-type,too-large(withmaxBytes),empty.declaredType(name, mime)is what a file claims to be: its MIME type with its other names folded (image/jpg), else its extension (.logand.mdfiles often come without a type). The Worker decides from the bytes.
In the Worker
import { attachmentResponse, storeAttachment } from '@codefusion-cc/attachments'
// PUT /api/files/<uuid>: the page made the id, so a retry stores under the same key.
const result = await storeAttachment(request, { bucket: env.FILES, key: `files/${id}`, policy: POLICY, metadata: { by: member.id } })
if (!result.ok) return Response.json({ error: copy[result.refusal], maxBytes: result.maxBytes }, { status: result.status })
await db.insert(result.attachment) // { key, type, size, name }
// GET /api/files/<uuid>
return attachmentResponse(env.FILES, `files/${id}`, request)storeAttachment resolves, never throws, for anything an upload can bring:
| Refusal | Status | When |
| --- | --- | --- |
| length-required | 411 | No Content-Length: the size must be known before anything is stored. |
| too-large | 413 | Over the limit of the type its bytes show, with maxBytes. A body over every limit is refused before a byte is read, and cancelled. |
| unsupported-type | 415 | Its first bytes show no type the policy takes, and it isn't declared as text. A page renamed .png is not a PNG. |
| not-text | 415 | Declared as text, but not UTF-8 or holding NUL bytes. |
| empty | 400 | No bytes. |
| incomplete | 400 | The upload broke off, or brought another number of bytes than it declared. Nothing is stored. |
- The type stored is the one the bytes show, whatever the request said: a PNG sent as
image/jpegis stored as a PNG. - Binary files stream straight into R2 (through workerd's
FixedLengthStream, as R2 needs a known length); text is read whole and stored only once every byte has been checked, so a text type's limit is also what one upload may hold in memory. - The name is the one
uploadAttachmentsent (theNAME_HEADER,x-attachment-name, %-encoded; one that isn't a %-encoding is ignored), ornamewhen the app gives one. It is cleaned (cleanName: no folders, control characters or reordering marks, at most 200 characters with its extension kept) and kept in the object's metadata asname. - The key is the app's to choose, and must never come from a person: use an id the server checks (a UUID).
attachmentResponse(bucket, key, request, options) answers GET and HEAD:
- 404 when it is not there, whatever the request's conditions.
- Then the preconditions, in RFC 9110's order: 412 when
If-Matchnames no current tag (a weak tag never matches,*always does) or, withoutIf-Match, the file changed afterIf-Unmodified-Since; 304 whenIf-None-Matchnames the current tag (weak or strong, or*) or, withoutIf-None-Match, the file has not changed sinceIf-Modified-Since. HEAD answers them as GET does. A date that is not an HTTP-date is ignored. - Then, for GET, 206 for one byte range (
bytes=10-19,bytes=90-,bytes=-5), so video seeks; 416 for a range outside the file; the whole file for several ranges or a malformed one, and for anIf-Rangethat names another version (a date counts only when it is exactlyLast-Modified, and from a second after it). ETag, andLast-Modifiedfrom the object'suploaded. A bucket of your own that gives nouploadedsends none, and its date conditions are ignored.- One read of R2:
getfor GET (only the range's bytes for a range),headfor HEAD. The conditions are decided from what it brings, not by R2, and the bytes of a 304, 412 or 416 are cancelled unsent. A failedIf-Rangereads the file again whole, and so do bytes other than the range asked (by therangeR2 reports); a range R2 refuses with an error is answered from the object'shead. Content-Typeis the stored one only when it is a type the package knows, elseapplication/octet-stream; text is; charset=utf-8.- Always
X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; …; sandbox,Cross-Origin-Resource-Policy: same-origin(cross-originwithpublic: true) andReferrer-Policy: no-referrer. Content-Dispositionnames the file (an ASCII form and the real one, RFC 6266):inlinefor pictures, video and text,attachmentotherwise. PDFs download by default, since browsers' PDF viewers don't open undersandbox;inline: (type) => …changes that.cache: 'immutable'(default) keeps it a year, for keys whose bytes never change;'revalidate'asks each time. Any other value rejects with a TypeError.public: trueis for files anyone may have (a shop's product photos): every cache may keep it (public, so a CDN and the Worker's Cache API too, which refuses a 206: put only a 200 there) and other sites may show it (Cross-Origin-Resource-Policy: cross-origin; no CORS, so their scripts cannot read it). By default only the browser keeps it (private) and only this site shows it.- Check who may read it before calling: the package serves whatever key it is given.
In the page
import { filesFromTransfer, carriesFiles, uploadAttachment, formatBytes } from '@codefusion-cc/attachments/browser'
const result = await uploadAttachment(`/api/files/${id}`, file, { signal, onProgress: (f) => bar.value = f })
if (!result.ok) show(result.reason) // 'network' | 'aborted' | 'timeout'
else if (result.status !== 200) show(result.body)uploadAttachmentsends the file as the whole body (PUT by default, so a retry replaces) with XMLHttpRequest, for its progress, and its name forstoreAttachment(a File's own, orname); it never rejects. No progress foridleTimeoutMs(60 s) ends it astimeout.filesFromTransfer(clipboardData or dataTransfer)is what a paste or drop brought, readingitemswhere a browser leavesfilesempty for a pasted screenshot.carriesFiles(dataTransfer)tells a drag of files from one of text, so a drop zone lets text through.
In React
import { useAttachments } from '@codefusion-cc/attachments/react'
const files = useAttachments({
policy: POLICY,
maxFiles: 10,
upload: async (file, { id, signal, onProgress }) => {
const r = await uploadAttachment(`/api/files/${id}`, file, { signal, onProgress })
return r.ok && r.status === 200 ? { ok: true } : { ok: false, message: 'Upload failed' }
},
onRemove: (item) => void fetch(`/api/files/${item.id}`, { method: 'DELETE' }),
})
<form {...files.dropTarget} onPaste={files.onPaste} data-dragging={files.dragging}>
<input type="file" multiple accept={acceptAttribute(POLICY)} onChange={(e) => { files.add(e.target.files ?? []); e.target.value = '' }} />
{files.items.map((item) => <Chip key={item.id} item={item} onRemove={() => files.remove(item.id)} onRetry={() => files.retry(item.id)} />)}
<button disabled={files.uploading}>Send</button>
</form>
// after sending: files.clear()- Each item:
id,name,type,size,state(uploading,ready,failedwitherror,refusedwithrefusalandmaxBytes),progress(0 to 1, in whole percents) andpreview(an object URL for PNG, JPEG, GIF, WebP and AVIF). - A file the policy refuses stays in the list, marked, until removed, so the person sees why; files past
maxFilesare refused astoo-many. retry(id)uploads a failed file again under its id. Only the newest try of a file may change it: a late answer or progress from an earlier try is ignored.remove(id)stops its upload, frees its preview, and callsonRemoveonce its upload had started (done, running, or failed: a failed upload may have reached the server).clear()forgets everything once the form is sent, withoutonRemove. Leaving the page stops every upload.dropTargetcounts nested drag events, reacts only to files, and setsdropEffecttocopy;onPastetakes pasted files and leaves pasted text to the field.- The package writes no words: refusals are codes, and the app says them in its own language.
formatBytes(n, locale)writes a size (3.2 MB,1,5 MBin Polish).
In an app's tests
import { sampleBytes, sampleFile, SAMPLE_TYPES } from '@codefusion-cc/attachments/testing'
sampleFile('image/png') // a real 1×1 PNG, named screenshot.png
sampleFile('image/png', { padTo: 20e6 + 1 }) // one byte over a 20 MB limit
sampleFile('image/png', { name: 'x.jpg', declare: 'image/jpeg' })Each sample is the smallest file the package's sniffing takes as its type, so no test writes magic numbers of its own.
