feat(freelancer): validate and downscale photo uploads on both forms

Both the application form (/odoo-community-developers/) and the self-service
edit form (/freelancer-edit/) now use one shared module, static/photo-upload.js
(window.O4PPhoto), so the limits live in one place:

- validate() decides on the File object BEFORE any read: type allowlist
  (image/jpeg, image/png, image/webp) and an 8 MiB cap. A refused file is read
  zero bytes and is never submitted.
- downscale() reads, centre-crops square, caps the longest side at 384 px,
  encodes JPEG at 0.82 and steps the quality down until the result is <= 80 KiB.
  Re-encoding also strips EXIF (a phone photo's GPS does not reach the page) and
  refuses a spoofed type that fails to decode.
- The edit form's photo field becomes a real labelled input that keeps the
  current photo when untouched; the whole-record save carries it back
  byte-identical.

Existing stored photos are untouched, so the directory page is not lighter until
profiles re-upload. accept= is a hint, not validation.
This commit is contained in:
webmaster committed 2026-11-03 19:07:07 +00:00
1 parent 5cf58ffa8a
commit 8319a74f99
3 files changed
+223 -27

No files matched your search

+46 -9
View File
@@ -148,6 +148,7 @@
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
<script src="/static/focus-rule.js"></script>
<script src="/static/photo-upload.js"></script>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
@@ -570,22 +571,53 @@
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
readField(record, "fiverr"), "https://www.fiverr.com/…"));
// The image is READ-ONLY on purpose: replacing a photo is a different problem from
// editing text (an upload path, a base64 blob, a size limit). Shown, not editable.
// The photo is editable here with the SAME rules as the application form
// (static/photo-upload.js). A picked file is validated and downscaled on the spot;
// leaving it untouched keeps the record's own Image, which rides back byte-identical
// because the save sends the whole record (see the submit handler below).
var newImage = null;
var photoWrap = el("div", "field");
photoWrap.appendChild(el("p", "", "Photo (not editable here)"));
var photoLabel = el("label", "", "Photo");
photoLabel.setAttribute("for", "edImage");
photoWrap.appendChild(photoLabel);
var photoRow = el("div", "photo-readonly");
var img = readField(record, "Image");
if (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) {
var im = el("img");
im.src = img;
im.alt = "";
photoRow.appendChild(im);
var currentImage = (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) ? img : null;
var previewImg = el("img");
previewImg.id = "edImagePreview";
previewImg.alt = "";
if (currentImage) {
previewImg.src = currentImage;
photoRow.appendChild(previewImg);
} else {
previewImg.hidden = true;
photoRow.appendChild(previewImg);
photoRow.appendChild(el("span", "mono", initials(who)));
}
photoRow.appendChild(el("p", "hint", "To change your photo, reply to the email that brought you here."));
photoWrap.appendChild(photoRow);
var photoInput = el("input");
photoInput.type = "file";
photoInput.id = "edImage";
photoInput.name = "Image";
photoInput.accept = "image/jpeg,image/png,image/webp";
photoWrap.appendChild(photoInput);
var photoResult = el("p", "hint", "");
photoResult.id = "edImageResult";
photoResult.setAttribute("role", "status");
photoResult.setAttribute("aria-live", "polite");
photoWrap.appendChild(photoResult);
photoWrap.appendChild(el("p", "hint",
"JPEG, PNG or WebP, up to 8 MB — we resize it to a square of at most 384 px first. " +
"Leave it to keep your current photo."));
O4PPhoto.wire(photoInput, previewImg, photoResult, function (dataUrl) {
newImage = dataUrl;
var fb = photoRow.querySelector(".mono");
if (fb) fb.hidden = !!dataUrl;
if (dataUrl) return; // wire already shows the new image
// reverted to "no new photo": show the record's own photo again
if (currentImage) { previewImg.src = currentImage; previewImg.hidden = false; }
else previewImg.hidden = true;
});
form.appendChild(photoWrap);
var save = el("button", "btn", "Save my changes");
@@ -651,6 +683,11 @@
else if (had) payload[pair[1]] = "";
});
// An untouched photo rides back exactly as loaded — it came in with the record
// above and was never touched, so it stays byte-identical. Only a NEWLY picked
// photo (already validated and downscaled) replaces it.
if (newImage !== null) payload.Image = newImage;
fetch(C.webhook, {
method: C.saveMethod,
headers: { "Content-Type": "application/json" },