Files people send the app
Photos and documents from the people using the app (a profile photo, a receipt, a CV with a job application) go in image and file fields. The browser sends them straight to storage through the SDK; Apphaven checks each one before anyone can use it and serves it only to people who may read its record.
This is different from the owner's own pictures (a logo, the menu photos, a brochure): those go through the app's Add files page into the workspace, and you reference them by path (see "Files from the user" in the overview).
Fields
models:
Profile:
fields:
name: string
owner: { type: user, default: $user.id, readonly: true, required: true }
photo: image # one photo
gallery: { type: image, many: true } # a list of photos
access: { read: true, create: $user != null, update: owner == $user.id, delete: owner == $user.id }
Application:
fields:
name: { type: string, required: true }
email: { type: string, format: email, required: true }
cv: { type: file, types: [pdf, document], max_mb: 10, required: true }
access: { read: false, write: false } # not through the API: people apply through the
# form below; the owner reads them on the Data page
| kind | accepted | shown how |
|---|---|---|
image |
JPEG, PNG, WebP, GIF (animated GIFs stay animated) | in the page: a display copy, 1,600 px on its long side, and a 320 px thumbnail |
pdf |
downloaded | |
document |
Word, Excel and PowerPoint (.docx, .xlsx, .pptx) | downloaded |
text |
plain text and CSV, as UTF-8 | downloaded |
type: imagetakes photos only.type: filetakes every kind unlesstypes:narrows it (types: [image, pdf]for a photo or a scan of a receipt).max_mb:the largest file, at most 25 MB (also the default). For photos it applies to the copy sent, which the SDK makes small, so phone photos of any size fit.many: truefor a list of up to 1,000 files (each save sends the whole list). A list nobody has saved yet reads asnull, not[].- Refused at the check, with a message for the person: Apple's HEIC photos the browser can't convert (Safari converts them; other browsers on other systems may not), pictures over 100 megapixels, GIFs over 1,000 frames, older .doc/.xls/.ppt files, Office files with macros, embedded objects or links to outside templates or files, password-protected Office files (a PDF with a password is accepted), and anything that isn't what it says it is. AVIF photos are converted to JPEG by the SDK where the browser can draw them.
- File fields can't be
unique,indexed or given adefault. Operations can take them asinput(create and update) but can'tsetthem, except tonull, which clears one. - A record stores each file's id (
att_…). A file belongs to one record: the same file can't be saved on two.
Sending a file, signed in
Pass the File from an <input type="file"> in the data. The SDK uploads and checks it first,
then saves the record with its id:
await app.db.Profile.update(profile.id, { photo: input.files[0] });
await app.db.Profile.update(profile.id, { gallery: [...(profile.gallery ?? []), ...input.files] }); // keeps the ones it has
What the SDK does: a photo is drawn on a canvas in the browser into a display copy and a
thumbnail (so its location and other metadata never leave the phone, and a 12-megapixel photo
becomes a few hundred KB); other files are sent as they are. Each copy goes straight to storage,
then the server checks what arrived. It resolves once the record is saved, or throws an
ApphavenError whose message is written for the person (see "What goes wrong").
To show progress, or the photo before saving, upload first and save the id:
import { useState } from "react";
import { createClient } from "@apphaven/client";
const app = createClient();
export function PhotoPicker({ profile, onSaved }) {
const [progress, setProgress] = useState(null);
const [error, setError] = useState("");
const [preview, setPreview] = useState(null);
async function choose(e) {
const file = e.target.files[0];
e.target.value = ""; // the same file can be chosen again after an error
if (!file) return;
setError("");
setPreview(URL.createObjectURL(file));
setProgress(0);
try {
const photo = await app.files.upload(file, { model: "Profile", field: "photo", onProgress: setProgress });
onSaved(await app.db.Profile.update(profile.id, { photo: photo.id }));
} catch (err) {
setError(err.message);
} finally {
setProgress(null);
setPreview(null);
}
}
const shown = preview ?? profile.photo?.thumb_url ?? profile.photo?.url;
return (
<div>
{shown ? <img src={shown} alt="" width="96" height="96" style={{ objectFit: "cover", borderRadius: "50%" }} /> : null}
<label>
{progress === null ? "Choose a photo" : `Sending… ${Math.round(progress * 100)}%`}
<input id="photo" type="file" accept="image/*" onChange={choose} disabled={progress !== null} hidden />
</label>
{error ? <p role="alert">{error}</p> : null}
</div>
);
}
app.files.upload(file, { model, field, onProgress })resolves to{ id, name, kind, content_type, size, width?, height?, variants }once the file is checked. For a form signed out, name the operation instead:{ operation: "apply", field: "cv", onProgress }.- Upload for the field you save it in: a file sent for
photocan't be saved ingallery. - Save it on a record within the hour: an upload nobody saves is deleted. Only the person who sent a file can save it, and at most 20 of theirs can wait unsaved at once.
- If a save fails for another reason (a required field missing, say), the files it sent are still waiting: upload first, keep the ids, and save again with the ids rather than the Files, which would send them again.
accept="image/*"(oraccept=".pdf,.docx") helps people pick the right thing; the server checks regardless.
A form for people who aren't signed in
Signed out, files come in only through a form: a create operation that takes the file field in
its input and that signed-out people may use (here access: true opens it to everyone).
operations:
apply:
model: Application
action: create
input: [name, email, cv]
access: true
async function submit(e) {
e.preventDefault();
const f = new FormData(e.target);
setSending(true);
try {
await app.ops.apply({ data: { name: f.get("name"), email: f.get("email"), cv: f.get("cv") } }); // an empty optional file input sends null
setDone(true);
} catch (err) {
setError(err.message);
} finally {
setSending(false);
}
}
// <form onSubmit={submit}> … <input name="cv" type="file" accept=".pdf,.docx" required /> …
The first file a visitor sends runs a quick check (Cloudflare Turnstile): usually invisible; at
most a small box at the bottom right of the page, which can ask for a click. (The SDK asks for it with
POST /_apphaven/uploads/session, which first answers 428: that's expected.) It lasts an hour for that form, and
files sent through one form can't be saved anywhere else. A browser has one such session at a
time: a visitor who starts a second form needs the check again for the first. A visitor can send
about 15 files a minute this way; for more, have people sign in. Keep the button disabled while
sending, and say what's happening: the check and the upload can take a few seconds on a phone.
The answer to a write the visitor can't read back (like read: false above) has each file's
id, name, kind, content_type, size and variants, without links: enough to say "we got
your CV (cv.pdf, 182 KB)".
Showing files
Reads return each file as an object (a list of them for many):
"photo": {
"id": "att_3k9…",
"name": "me.jpg",
"kind": "image",
"content_type": "image/jpeg",
"size": 183204,
"width": 1600,
"height": 1200,
"variants": ["display", "thumb"],
"url": "https://my-app.onapphaven.com/_apphaven/files/att_3k9…",
"thumb_url": "https://my-app.onapphaven.com/_apphaven/files/att_3k9…?v=thumb",
"signed_url": "https://onapphaven-files.com/…?verify=…",
"signed_thumb_url": "https://onapphaven-files.com/…?verify=…",
"signed_expires_at": "2026-11-02T15:00:00.000Z"
}
- In pages, use
urlandthumb_url. They're the file's stable address on the app: each time, it checks the person may read the record and sends them on to the file. Usethumb_urlin lists and grids,urlfor the full photo.thumb_urlis there only for photos with a thumbnail (not documents, nor a photo the SDK couldn't prepare): fall back tourl. signed_urlworks without signing in untilsigned_expires_at(one to two hours): for a download link right now. Never store it, email it or share it.- Photos show in the page. PDFs, documents and text files download when opened: link them with the
file's
nameand size rather than embedding them. expandon a reference brings the related record's files with their links too.{ "id": "att_…", "removed": true }: the file was removed. Show nothing, or "removed".{ "id": "att_…", "unavailable": true }: files can't be shown right now. Show a placeholder; it's back on the next read.
Removing and replacing
Set the field to null, or save the list without the file, and it's removed; deleting the record
removes its files. Replacing a photo is saving the new one in its place. A removed file stops
working within moments, though a browser that opened it in the last hour can still show it from
its cache. Apphaven keeps removed files 30 days, then deletes them.
Access is checked whenever the stable address is opened, but a signed_url someone already has
keeps working until it expires: after making a record private, or removing someone from it,
allow up to two hours for links they already had.
What goes wrong
Every refusal has a code and a message for the person: show the message as it is.
| code | when | what to do |
|---|---|---|
too_large, not_accepted, heic, too_many_pixels, too_many_frames, macros, old_office, encrypted, not_text, damaged, empty |
the file didn't pass the check, or isn't a kind the field takes (the message says what it takes) | show the message; let them pick another file |
bad_upload |
the page sent the file's copies wrong | send files through the SDK, which gets them right |
upload_failed |
the connection dropped | offer to try again |
busy |
many files are being checked at once | try again in a moment |
too_many_uploads |
20 files are waiting unsaved | save or wait, then try again |
file_gone, expired |
it waited over an hour unsaved | send it again |
file_in_use |
it's already saved on another record | send it again for this one |
wrong_field |
it was sent for another field | upload it for the field you save it in |
blocked |
the same file was taken down by Apphaven after a report | it can't be sent |
upload_incomplete |
the copies hadn't all arrived | send it again (the SDK tries a few times first) |
rate_limited |
too many requests from one person in a minute | wait a minute |
files_unavailable |
files can't be sent or shown right now | try again later |
not_yours |
someone else sent it, or it was sent for another form | each person sends their own |
challenge_failed |
the signed-out check didn't pass | try again |
files_limited |
the app's file storage is full | the person sees "This app can't take more files right now. Let its owner know."; the owner frees space or moves plan (the test version: 1 GB) |
The test version and the live app
Each keeps its own files. The test version holds up to 1 GB; the live app's files count against
its plan's storage, shown in app_status. When the owner publishes with the test version's data,
the files its records hold come along. On the test version, the signed-out check always passes
(it's private), so your tests and the owner can try a form freely; on the live app it's real.
Checking your work
test_api_writewithattachsends a sample file for a field first, the way the SDK does, then makes the write with it:{ "as": "alice", "method": "PATCH", "path": "/api/models/Profile/<id>", "attach": { "photo": "photo" } }. Samples:photo,pdf,text. Signed out, write through the form ("path": "/api/ops/apply", "body": { "data": { … } }, "attach": { "cv": "pdf" }).view_pagewith a step{ "attach": "#photo", "sample": "photo" }puts a phone-sized photo in the page's file input, as if a person chose it, so your page's own upload code runs. Then wait for something that appears only once the upload is done and saved (a preview drawn from the chosen file shows before that), and look at the screenshot. A file link that downloads shows under "Downloads", not as a failure.test_apiwith"path": "/_apphaven/files/<id>"andassays whether that person may open the file: "Allowed", or not. Try the owner of the record, someone else, and signed out.query_datashows each file's name, size, kind and state (saved, removed, refused and why, taken down), never a link.change_datacan't put files on records (they come from uploads); it can clear a file field or delete records, which lets their files go.app_statushas anattachmentssection and notes: uploads refused in the last week and why, photos that arrived without a thumbnail (they didn't go through the SDK), and how full the test version is.