Files
webmaster 8319a74f99 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.
2026-11-03 19:07:07 +00:00

142 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
};
})();