Public Access
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.
142 lines
5.6 KiB
JavaScript
142 lines
5.6 KiB
JavaScript
/* 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
|
||
};
|
||
})();
|