Ordinary forms & companion mode

Use hidden JSON fields with ordinary HTML forms, validate file IDs on the server, and handle mixed multipart + ID contracts.

Ordinary HTML form (picker mode)

In picker mode every file — local or phone — goes through CrossUpload, and your form receives one consistent, ID-based contract.

<form method="post" action="/claims">
  <label>Claim number <input name="claim" required></label>
  <button type="button" id="photos">Upload photos</button>
  <input type="hidden" id="photo_ids" name="photo_ids" value="[]">
  <button type="submit">Submit claim</button>
</form>
<script src="https://app.crossupload.com/v1/widget.js"></script>
<script>
  CrossUpload.attach({ target: '#photos', sessionEndpoint: '/api/crossupload/session', resultInput: '#photo_ids', maxFiles: 5 });
</script>

Server-side validation (required)

Hidden fields are attacker-controlled. Never trust IDs or URLs posted from them.

for posted in serde_json::from_str::<Vec<Posted>>(&form.photo_ids)? {
    let f: Value = crossupload.get(format!("/v1/files/{}", posted.id)).await?; // secret key
    let ok = f["status"] == "ready"
        && f["projectId"] == MY_PROJECT_ID
        && sessions_issued_to(user).contains(f["sessionId"].as_str().unwrap_or(""))
        && f["externalReference"] == format!("user:{}:claim:{}", user.id, claim.id);
    if !ok { return Err(BadRequest("invalid attachment")); }
    db.attach_file(claim.id, posted.id).await?;   // store the ID, not the URL
}

A 404 means the ID doesn’t exist or belongs to another project — treat both as invalid.

Companion mode: the mixed contract

Companion mode adds “Upload from your phone” next to an existing <input type="file"> and leaves that input untouched. Your backend can therefore receive two kinds of files in one submission:

  • local files as normal multipart/form-data parts (exactly as before), and
  • phone files as CrossUpload file IDs in a hidden field.
<form method="post" action="/applications" enctype="multipart/form-data">
  <input type="file" id="cv" name="cv">
  <input type="hidden" id="cv_phone" name="cv_phone_ids" value="[]">
  <button type="submit">Apply</button>
</form>
<script>
  CrossUpload.attach({ target: '#cv', mode: 'companion', sessionEndpoint: '/api/crossupload/session', resultInput: '#cv_phone' });
</script>

Handle both explicitly:

while let Some(field) = multipart.next_field().await? {
    match field.name() {
        Some("cv") if field.file_name().is_some_and(|n| !n.is_empty()) => {
            let bytes = field.bytes().await?;           // legacy local path
            store_local_upload(&bytes).await?;
        }
        Some("cv_phone_ids") => {
            let ids: Vec<Posted> = serde_json::from_str(&field.text().await?)?;
            for p in ids { validate_and_attach(p.id).await?; }   // CrossUpload path
        }
        _ => {}
    }
}

The repository’s examples/customer-backend implements exactly this.

Why not just fill the file input? Browsers don’t let scripts create a native FileList from a URL, and assigning a URL to <input type="file"> does nothing. A multipart-only backend needs an adapter or a small change to accept file IDs. A fetch-Blob-File compatibility adapter is planned for a later release.

Something unclear or wrong? Email master@crossupload.com.