File Uploads
Keep upload actions in the route that owns the workflow. Caspian sends
files through @rpc()
and pp.rpc();
ordinary uploads do not need a custom fetch endpoint or WebSocket.
"prisma": true is
required only when an app uses the generated ORM to persist file metadata;
the storage provider and its credentials remain app-owned.
Route-owned server action
Validate the upload before writing it. Enforce size and extension or
MIME rules on the server, generate a safe filename, and keep shared
storage helpers in src/lib/** only when
more than one route uses them.
from casp.rpc import rpc from casp.validate import Rule, Validate @rpc() async def upload_avatar(avatar): result = Validate.with_rules( avatar, [Rule.REQUIRED, Rule.extensions(["png", "jpg", "jpeg"])], ) if result is not True: return "error": result # Normalize the name and write through an app-owned storage helper. saved = await save_avatar(avatar) return "file": saved
PulsePoint form
Submit the named file field through the normal form event. Use the RPC options object when the UI needs upload progress.
<form onsubmit="submitUpload(event)"> <input name="avatar" type="file" accept=".png,.jpg,.jpeg" required /> <button disabled="uploading">Upload</button> <script> const [uploading, setUploading] = pp.state(false); const [progress, setProgress] = pp.state(0); async function submitUpload(event) event.preventDefault(); setUploading(true); const data = Object.fromEntries( new FormData(event.currentTarget).entries() ); try await pp.rpc("upload_avatar", data, // receives loaded, total, percent — there is no `percentage` field onUploadProgress: (info) => setProgress(Math.round(info.percent ?? 0)), ); finally setUploading(false); </script> </form>
Local disk is fine until you deploy.
A container filesystem does not survive a redeploy, so
public/uploads/** is a good default for
development and for assets you can regenerate — and data loss for
anything a user uploaded. It also cannot be shared across replicas.
The fix is the ordinary one: keep the bytes in object storage and the metadata in your database. Read Production Storage for the durable file architecture, then Deployment for the rest of what goes ephemeral.
Recommended managed storage
Dilnaka Storage
Dilnaka Storage
is a secure-by-default S3 file platform: zero-config buckets, IAM policies
generated per request, and global edge delivery — so a file feature does
not start with a week of bucket policy, CORS, and public-access-block
archaeology. It ships a typed Python SDK, which is what makes it a natural fit
behind a Caspian @rpc().
Credentials never leave the server
The SDK is handed a short-lived presigned URL, never AWS credentials. Bytes go straight to S3 while your Python stays the source of truth for validation, key generation, and metadata.
Expiring share links
Share one file, a selection, or a whole folder as a public download page with a countdown — 5 minutes to 30 days — revocable at any time. No account needed on the other end.
A rotated key is not a re-upload
On paid plans each API key owns an isolated file store. Revoking a key detaches it but keeps every file, and the store can be reassigned to the replacement key.
Install and configure
# pypi.org/project/dilnaka pip install dilnaka # .env -- the only required value DILNAKA_API_KEY=dlk_dev_your_api_key_here
Optional overrides: DILNAKA_TIMEOUT
(default 60s, the JSON API timeout),
DILNAKA_MULTIPART_THRESHOLD
(default 100 MB, where uploads switch to multipart), and
DILNAKA_UPLOAD_TIMEOUT
(default 300s per part — deliberately longer than the API timeout so a
large chunk on a slow link does not abort).
In a route-owned action
Validate first, upload second, persist the returned id, and hand the browser a
temporary URL rather than a permanent one. Move the client into a reusable
src/lib/** adapter once more than one
route uses it.
from casp.rpc import rpc from casp.validate import Rule, Validate from dilnaka import Dilnaka client = Dilnaka() @rpc(require_auth=True) async def upload_avatar(avatar): result = Validate.with_rules( avatar, [Rule.REQUIRED, Rule.extensions(["png", "jpg", "jpeg"])], ) if result is not True: return "error": result uploaded = client.upload(local_path, folder="avatars") # Persist the id -- it is what you resolve URLs from later. await prisma.asset.create(data= "fileId": uploaded.id, "key": uploaded.key, "size": uploaded.size, "contentType": uploaded.content_type, ) access = client.get_file_access_url(uploaded.id, expires_in=1200) return "url": access.url
list_files() and
delete_file(file_id) round out the
lifecycle. Files at or above the multipart threshold switch automatically to a
resumable upload that streams from disk one part at a time — memory stays
flat on a multi-gigabyte file, a failed part is retried with a fresh presigned
URL instead of discarding the transfer, and an upload that cannot finish is
aborted so it leaves nothing dangling on the bucket.
TypeScript and PHP SDKs share the same upload contract, and a hosted MCP endpoint lets an agent create applications and manage files with a personal access token — useful when the same assets are maintained by tooling as well as by the app.
Public files
public/uploads/** is served at
/uploads/** by
PublicFilesMiddleware. Directories listed in its
inline_safe_subdirectories mapping serve
user content in attachment mode — only
allow-listed real image types render inline. Add any other top-level public
directory that receives untrusted uploads to that mapping before storing
files there, and leave trusted first-party assets inline.
Operational checks
Respect MAX_CONTENT_LENGTH_MB, avoid
trusting client filenames, and configure BrowserSync to ignore
upload directories when writes would otherwise trigger reloads.