Public Access
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:
1 parent
5cf58ffa8a
commit
8319a74f99
3 files changed
+223
-27
No files matched your search
@@ -148,6 +148,7 @@
|
|||||||
|
|
||||||
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
|
<!-- 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/focus-rule.js"></script>
|
||||||
|
<script src="/static/photo-upload.js"></script>
|
||||||
|
|
||||||
<!-- 1) Read the credential, then take it OUT of the address bar.
|
<!-- 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
|
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
|
||||||
@@ -570,22 +571,53 @@
|
|||||||
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
|
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
|
||||||
readField(record, "fiverr"), "https://www.fiverr.com/…"));
|
readField(record, "fiverr"), "https://www.fiverr.com/…"));
|
||||||
|
|
||||||
// The image is READ-ONLY on purpose: replacing a photo is a different problem from
|
// The photo is editable here with the SAME rules as the application form
|
||||||
// editing text (an upload path, a base64 blob, a size limit). Shown, not editable.
|
// (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");
|
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 photoRow = el("div", "photo-readonly");
|
||||||
var img = readField(record, "Image");
|
var img = readField(record, "Image");
|
||||||
if (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) {
|
var currentImage = (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) ? img : null;
|
||||||
var im = el("img");
|
var previewImg = el("img");
|
||||||
im.src = img;
|
previewImg.id = "edImagePreview";
|
||||||
im.alt = "";
|
previewImg.alt = "";
|
||||||
photoRow.appendChild(im);
|
if (currentImage) {
|
||||||
|
previewImg.src = currentImage;
|
||||||
|
photoRow.appendChild(previewImg);
|
||||||
} else {
|
} else {
|
||||||
|
previewImg.hidden = true;
|
||||||
|
photoRow.appendChild(previewImg);
|
||||||
photoRow.appendChild(el("span", "mono", initials(who)));
|
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);
|
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);
|
form.appendChild(photoWrap);
|
||||||
|
|
||||||
var save = el("button", "btn", "Save my changes");
|
var save = el("button", "btn", "Save my changes");
|
||||||
@@ -651,6 +683,11 @@
|
|||||||
else if (had) payload[pair[1]] = "";
|
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, {
|
fetch(C.webhook, {
|
||||||
method: C.saveMethod,
|
method: C.saveMethod,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -431,6 +431,14 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid var(--line-brass);
|
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 {
|
.dev-name {
|
||||||
font-family: Marcellus, Georgia, serif;
|
font-family: Marcellus, Georgia, serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -1814,8 +1822,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="apImage">Photo <span class="hint">(optional)</span></label>
|
<label for="apImage">Photo <span class="hint">(optional)</span></label>
|
||||||
<input id="apImage" name="Image" type="file" accept="image/*" />
|
<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.</span>
|
<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>
|
</div>
|
||||||
<label class="form-check">
|
<label class="form-check">
|
||||||
<input type="checkbox" name="is_individual" value="1" required />
|
<input type="checkbox" name="is_individual" value="1" required />
|
||||||
@@ -1950,6 +1959,7 @@
|
|||||||
|
|
||||||
<script defer src="/static/lang.js"></script>
|
<script defer src="/static/lang.js"></script>
|
||||||
<script src="/static/focus-rule.js"></script>
|
<script src="/static/focus-rule.js"></script>
|
||||||
|
<script src="/static/photo-upload.js"></script>
|
||||||
<script>
|
<script>
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Odoo freelancer directory — page script.
|
Odoo freelancer directory — page script.
|
||||||
@@ -2146,15 +2156,6 @@
|
|||||||
refresh();
|
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) {
|
function setStatus(el, message, state) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
@@ -2921,6 +2922,23 @@
|
|||||||
var form = document.getElementById("applyForm");
|
var form = document.getElementById("applyForm");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
var status = document.getElementById("applyStatus");
|
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) {
|
form.addEventListener("submit", function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!form.reportValidity()) return;
|
if (!form.reportValidity()) return;
|
||||||
@@ -2940,14 +2958,9 @@
|
|||||||
setStatus(status, "Sending…", null);
|
setStatus(status, "Sending…", null);
|
||||||
|
|
||||||
var payload = buildPayload(form, false); // no id → create
|
var payload = buildPayload(form, false); // no id → create
|
||||||
var fileEl = form.querySelector('[name="Image"]');
|
if (applyImage) payload.Image = applyImage;
|
||||||
var file = fileEl && fileEl.files ? fileEl.files[0] : null;
|
|
||||||
var withImage = file
|
|
||||||
? readImageAsDataUrl(file).then(function (dataUrl) { payload.Image = dataUrl; })
|
|
||||||
: Promise.resolve();
|
|
||||||
|
|
||||||
withImage
|
postTo(ODOO_EXPERTS_WEBHOOK, payload)
|
||||||
.then(function () { return postTo(ODOO_EXPERTS_WEBHOOK, payload); })
|
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -2956,6 +2969,11 @@
|
|||||||
"ok"
|
"ok"
|
||||||
);
|
);
|
||||||
form.reset();
|
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");
|
initFocusUI(form, "apFocusWarn", "apFocusTotal");
|
||||||
initFocusCounter(form, "apFocus", "apFocusCount");
|
initFocusCounter(form, "apFocus", "apFocusCount");
|
||||||
initFocusCounter(form, "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
initFocusCounter(form, "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* The ONE source of truth for photo uploads — limits, validation and downscale.
|
||||||
|
Two forms call this: the directory application form (/odoo-community-developers/)
|
||||||
|
and the private self-service edit form (/freelancer-edit/). The limits live here
|
||||||
|
once, so the two cannot drift. Loaded before each page's own script.
|
||||||
|
|
||||||
|
Why this exists beyond page weight: re-encoding to a canvas DROPS EXIF entirely.
|
||||||
|
A phone photo carries GPS coordinates, so an un-stripped portrait publishes the
|
||||||
|
photographer's home location. Re-encoding is also a security step — a file whose
|
||||||
|
declared type was spoofed fails to decode and is refused here.
|
||||||
|
|
||||||
|
NOTE: the <input accept> attribute is a picker HINT, never validation. Only
|
||||||
|
validate() below decides, and it runs on the File object before any read. */
|
||||||
|
(function () {
|
||||||
|
var MAX_INPUT_BYTES = 8 * 1024 * 1024; // 8 MB — refuse before reading a byte
|
||||||
|
var ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
var MAX_SIDE = 384; // longest side of the stored image, px
|
||||||
|
var MAX_OUTPUT_BYTES = 80 * 1024; // 80 KB — the encoded JPEG target
|
||||||
|
var QUALITY = 0.82; // starting JPEG quality
|
||||||
|
var QUALITY_STEP = 0.05; // step down until the target is met
|
||||||
|
var QUALITY_FLOOR = 0.4; // never go below this
|
||||||
|
|
||||||
|
function kb(bytes) { return Math.round(bytes / 1024); }
|
||||||
|
function mb(bytes) { return (bytes / (1024 * 1024)).toFixed(1); }
|
||||||
|
|
||||||
|
/* Decide on the File's own metadata — no read happens here. */
|
||||||
|
function validate(file) {
|
||||||
|
if (!file) return { ok: false, reason: "No file was chosen." };
|
||||||
|
if (ALLOWED_TYPES.indexOf(file.type) === -1) {
|
||||||
|
return { ok: false, reason: "That file type is not allowed. Use a JPEG, PNG or WebP image." };
|
||||||
|
}
|
||||||
|
if (file.size > MAX_INPUT_BYTES) {
|
||||||
|
return { ok: false, reason: "That photo is " + mb(file.size) + " MB. The limit is 8 MB — please choose a smaller one." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(file) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () { resolve(reader.result); };
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () { resolve(img); };
|
||||||
|
img.onerror = reject; // a spoofed "image/jpeg" that is not an image lands here
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteLength(dataUrl) {
|
||||||
|
return atob(dataUrl.slice(dataUrl.indexOf(",") + 1)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read → centre-crop square → cap longest side → encode JPEG → step quality down
|
||||||
|
until it fits. Resolves { dataUrl, width, height, bytes, quality }. */
|
||||||
|
function downscale(file) {
|
||||||
|
return readAsDataUrl(file).then(loadImage).then(function (img) {
|
||||||
|
var w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
var side = Math.min(w, h);
|
||||||
|
var sx = (w - side) / 2, sy = (h - side) / 2;
|
||||||
|
var out = Math.min(MAX_SIDE, side);
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
canvas.width = out;
|
||||||
|
canvas.height = out;
|
||||||
|
canvas.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, out, out);
|
||||||
|
var quality = QUALITY;
|
||||||
|
var dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||||
|
var bytes = byteLength(dataUrl);
|
||||||
|
while (bytes > MAX_OUTPUT_BYTES && quality > QUALITY_FLOOR) {
|
||||||
|
quality = Math.max(QUALITY_FLOOR, quality - QUALITY_STEP);
|
||||||
|
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||||
|
bytes = byteLength(dataUrl);
|
||||||
|
}
|
||||||
|
return { dataUrl: dataUrl, width: out, height: out, bytes: bytes, quality: quality };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one honest line the person sees. No "optimised for the web" — only what we can show. */
|
||||||
|
function resultLine(res) {
|
||||||
|
return "Resized to " + res.width + " × " + res.height + ", " + kb(res.bytes) + " KB";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wire one file input to a preview <img> and a status line. onResult receives the
|
||||||
|
downscaled data URL, or null when there is no (valid) file. Both forms use this,
|
||||||
|
so the outcome shown is identical. */
|
||||||
|
function wire(input, preview, result, onResult) {
|
||||||
|
if (!input) return;
|
||||||
|
function say(text, state) {
|
||||||
|
if (!result) return;
|
||||||
|
result.textContent = text || "";
|
||||||
|
if (state) result.setAttribute("data-state", state);
|
||||||
|
else result.removeAttribute("data-state");
|
||||||
|
}
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) {
|
||||||
|
if (preview) preview.hidden = true;
|
||||||
|
say("", null);
|
||||||
|
onResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var v = validate(file);
|
||||||
|
if (!v.ok) {
|
||||||
|
if (preview) preview.hidden = true;
|
||||||
|
input.value = ""; // a refused file must never be submitted
|
||||||
|
say(v.reason, "err");
|
||||||
|
onResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say("Processing…", null);
|
||||||
|
downscale(file).then(function (res) {
|
||||||
|
if (preview) { preview.src = res.dataUrl; preview.hidden = false; }
|
||||||
|
say(resultLine(res), "ok");
|
||||||
|
onResult(res.dataUrl, res);
|
||||||
|
}).catch(function () {
|
||||||
|
if (preview) preview.hidden = true;
|
||||||
|
input.value = "";
|
||||||
|
say("That file could not be read as an image. Please choose a JPEG, PNG or WebP.", "err");
|
||||||
|
onResult(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.O4PPhoto = {
|
||||||
|
MAX_INPUT_BYTES: MAX_INPUT_BYTES,
|
||||||
|
ALLOWED_TYPES: ALLOWED_TYPES,
|
||||||
|
MAX_SIDE: MAX_SIDE,
|
||||||
|
MAX_OUTPUT_BYTES: MAX_OUTPUT_BYTES,
|
||||||
|
QUALITY: QUALITY,
|
||||||
|
validate: validate,
|
||||||
|
downscale: downscale,
|
||||||
|
resultLine: resultLine,
|
||||||
|
kb: kb,
|
||||||
|
wire: wire
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user