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

+36 -18
View File
@@ -431,6 +431,14 @@
object-fit: cover;
border: 1px solid var(--line-brass);
}
.photo-preview {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line-brass);
margin: 0.5rem 0 0;
}
.dev-name {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
@@ -1814,8 +1822,9 @@
</div>
<div class="form-field">
<label for="apImage">Photo <span class="hint">(optional)</span></label>
<input id="apImage" name="Image" type="file" accept="image/*" />
<span class="hint">Leave it out if you prefer. If you add one it goes on your card, and it is checked before it is published.</span>
<input id="apImage" name="Image" type="file" accept="image/jpeg,image/png,image/webp" />
<span class="hint">Leave it out if you prefer. If you add one it goes on your card, and it is checked before it is published. JPEG, PNG or WebP, up to 8 MB — we resize it to a square of at most 384 px first.</span>
<span class="hint" id="apImageResult" role="status" aria-live="polite"></span>
</div>
<label class="form-check">
<input type="checkbox" name="is_individual" value="1" required />
@@ -1950,6 +1959,7 @@
<script defer src="/static/lang.js"></script>
<script src="/static/focus-rule.js"></script>
<script src="/static/photo-upload.js"></script>
<script>
/* ============================================================================
Odoo freelancer directory — page script.
@@ -2146,15 +2156,6 @@
refresh();
}
function readImageAsDataUrl(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(reader.result); };
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function setStatus(el, message, state) {
if (!el) return;
el.textContent = message;
@@ -2921,6 +2922,23 @@
var form = document.getElementById("applyForm");
if (!form) return;
var status = document.getElementById("applyStatus");
// The photo is validated and downscaled the moment it is chosen (static/photo-upload.js);
// the submit only uses the already-resized data URL, so nothing is read at submit time.
// The preview element is built here (not in the markup) so no empty-src <img> ships.
var applyImage = null;
var resultEl = document.getElementById("apImageResult");
var previewEl = document.createElement("img");
previewEl.id = "apImagePreview";
previewEl.className = "photo-preview";
previewEl.alt = "";
previewEl.hidden = true;
if (resultEl && resultEl.parentNode) resultEl.parentNode.insertBefore(previewEl, resultEl);
O4PPhoto.wire(
form.querySelector('[name="Image"]'),
previewEl,
resultEl,
function (dataUrl) { applyImage = dataUrl; }
);
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
@@ -2940,14 +2958,9 @@
setStatus(status, "Sending…", null);
var payload = buildPayload(form, false); // no id → create
var fileEl = form.querySelector('[name="Image"]');
var file = fileEl && fileEl.files ? fileEl.files[0] : null;
var withImage = file
? readImageAsDataUrl(file).then(function (dataUrl) { payload.Image = dataUrl; })
: Promise.resolve();
if (applyImage) payload.Image = applyImage;
withImage
.then(function () { return postTo(ODOO_EXPERTS_WEBHOOK, payload); })
postTo(ODOO_EXPERTS_WEBHOOK, payload)
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
@@ -2956,6 +2969,11 @@
"ok"
);
form.reset();
applyImage = null;
var pv = document.getElementById("apImagePreview");
if (pv) pv.hidden = true;
var pr = document.getElementById("apImageResult");
if (pr) pr.textContent = "";
initFocusUI(form, "apFocusWarn", "apFocusTotal");
initFocusCounter(form, "apFocus", "apFocusCount");
initFocusCounter(form, "apDescription", "apDescriptionCount", DESC_MAX_CHARS);