fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile - Fix design token violations (text-red-500, fallback colors) - Add disconnectedCallback to chip-scroll, image-upload, searchable-select - Replace innerHTML with safe DOM APIs in world-map screen reader table - Add credentials: same-origin to WebAuthn fetch calls - Move page-specific scripts (donut-chart, location) out of base.html - Add client-side image resizing (1920px max dimension) - Resize app-icon-512.png from 2048x2048 to 512x512 - Document Datastar unsafe-eval CSP requirement - Add static asset serving tests (16 routes) - Add e2e tests for world-map, donut-chart, chip-scroll presence - Add cache-busting query params to all static asset URLs
This commit is contained in:
parent
920931ba17
commit
5820c162f6
21 changed files with 437 additions and 125 deletions
|
|
@ -49,6 +49,7 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
axum::http::header::REFERRER_POLICY,
|
axum::http::header::REFERRER_POLICY,
|
||||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
))
|
))
|
||||||
|
// Datastar v1 evaluates expressions via Function(), requiring 'unsafe-eval'.
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
axum::http::header::CONTENT_SECURITY_POLICY,
|
axum::http::header::CONTENT_SECURITY_POLICY,
|
||||||
HeaderValue::from_static(
|
HeaderValue::from_static(
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 44 KiB |
|
|
@ -465,7 +465,6 @@ input.input-field[type="number"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
|
||||||
transition:
|
transition:
|
||||||
background-color 150ms ease,
|
background-color 150ms ease,
|
||||||
color 150ms ease;
|
color 150ms ease;
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,22 @@ customElements.define(
|
||||||
this._setup();
|
this._setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._observer?.disconnect();
|
||||||
|
this._resizeObserver?.disconnect();
|
||||||
|
this._scroller?.removeEventListener("scroll", this._scrollHandler);
|
||||||
|
}
|
||||||
|
|
||||||
_setup() {
|
_setup() {
|
||||||
if (this._observer) this._observer.disconnect();
|
this.disconnectedCallback();
|
||||||
|
|
||||||
const scroller = this.querySelector("[data-chip-scroll]");
|
const scroller = this.querySelector("[data-chip-scroll]");
|
||||||
const btnL = this.querySelector("[data-scroll-left]");
|
const btnL = this.querySelector("[data-scroll-left]");
|
||||||
const btnR = this.querySelector("[data-scroll-right]");
|
const btnR = this.querySelector("[data-scroll-right]");
|
||||||
if (!scroller || !btnL || !btnR) return;
|
if (!scroller || !btnL || !btnR) return;
|
||||||
|
|
||||||
|
this._scroller = scroller;
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (window.matchMedia("(max-width: 767px)").matches) {
|
if (window.matchMedia("(max-width: 767px)").matches) {
|
||||||
btnL.style.display = "none";
|
btnL.style.display = "none";
|
||||||
|
|
@ -26,8 +34,10 @@ customElements.define(
|
||||||
: "none";
|
: "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this._scrollHandler = update;
|
||||||
scroller.addEventListener("scroll", update, { passive: true });
|
scroller.addEventListener("scroll", update, { passive: true });
|
||||||
new ResizeObserver(update).observe(scroller);
|
this._resizeObserver = new ResizeObserver(update);
|
||||||
|
this._resizeObserver.observe(scroller);
|
||||||
|
|
||||||
this._observer = new MutationObserver(update);
|
this._observer = new MutationObserver(update);
|
||||||
this._observer.observe(scroller, { childList: true });
|
this._observer.observe(scroller, { childList: true });
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class DonutChart extends HTMLElement {
|
||||||
const rgb =
|
const rgb =
|
||||||
getComputedStyle(document.documentElement)
|
getComputedStyle(document.documentElement)
|
||||||
.getPropertyValue("--highlight-rgb")
|
.getPropertyValue("--highlight-rgb")
|
||||||
.trim() || "185, 28, 28";
|
.trim() || "194, 65, 12";
|
||||||
const colorFor = (count) => {
|
const colorFor = (count) => {
|
||||||
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
|
const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2);
|
||||||
return `rgba(${rgb}, ${alpha})`;
|
return `rgba(${rgb}, ${alpha})`;
|
||||||
|
|
|
||||||
|
|
@ -2,39 +2,66 @@ customElements.define(
|
||||||
"image-upload",
|
"image-upload",
|
||||||
class extends HTMLElement {
|
class extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
this._ac = new AbortController();
|
||||||
|
const { signal } = this._ac;
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*";
|
||||||
input.hidden = true;
|
input.hidden = true;
|
||||||
this.appendChild(input);
|
this.appendChild(input);
|
||||||
|
|
||||||
this.addEventListener("click", (e) => {
|
this.addEventListener(
|
||||||
if (e.target !== input) input.click();
|
"click",
|
||||||
});
|
(e) => {
|
||||||
|
if (e.target !== input) input.click();
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
this.addEventListener("dragover", (e) => {
|
this.addEventListener(
|
||||||
e.preventDefault();
|
"dragover",
|
||||||
this.classList.add("border-accent");
|
(e) => {
|
||||||
});
|
e.preventDefault();
|
||||||
|
this.classList.add("border-accent");
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
this.addEventListener("dragleave", () => {
|
this.addEventListener(
|
||||||
this.classList.remove("border-accent");
|
"dragleave",
|
||||||
});
|
() => {
|
||||||
|
this.classList.remove("border-accent");
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
this.addEventListener("drop", (e) => {
|
this.addEventListener(
|
||||||
e.preventDefault();
|
"drop",
|
||||||
this.classList.remove("border-accent");
|
(e) => {
|
||||||
const file = e.dataTransfer?.files[0];
|
e.preventDefault();
|
||||||
if (file && file.type.startsWith("image/")) {
|
this.classList.remove("border-accent");
|
||||||
this._handleFile(file);
|
const file = e.dataTransfer?.files[0];
|
||||||
}
|
if (file && file.type.startsWith("image/")) {
|
||||||
});
|
this._handleFile(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener(
|
||||||
const file = input.files[0];
|
"change",
|
||||||
if (file) this._handleFile(file);
|
() => {
|
||||||
input.value = "";
|
const file = input.files[0];
|
||||||
});
|
if (file) this._handleFile(file);
|
||||||
|
input.value = "";
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._ac?.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _handleFile(file) {
|
async _handleFile(file) {
|
||||||
|
|
@ -113,7 +140,7 @@ customElements.define(
|
||||||
} catch {
|
} catch {
|
||||||
this.innerHTML = originalContent;
|
this.innerHTML = originalContent;
|
||||||
const errEl = document.createElement("p");
|
const errEl = document.createElement("p");
|
||||||
errEl.className = "text-xs text-red-500 mt-1";
|
errEl.className = "text-xs text-error mt-1";
|
||||||
errEl.textContent = "Upload failed. Try again.";
|
errEl.textContent = "Upload failed. Try again.";
|
||||||
this.parentElement.appendChild(errEl);
|
this.parentElement.appendChild(errEl);
|
||||||
setTimeout(() => errEl.remove(), 3000);
|
setTimeout(() => errEl.remove(), 3000);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,16 @@ customElements.define(
|
||||||
requestAnimationFrame(() => this._setup());
|
requestAnimationFrame(() => this._setup());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._ac?.abort();
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
_setup() {
|
_setup() {
|
||||||
if (this._initialized) return;
|
if (this._initialized) return;
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
this._ac = new AbortController();
|
||||||
|
const { signal } = this._ac;
|
||||||
|
|
||||||
const name = this.getAttribute("name");
|
const name = this.getAttribute("name");
|
||||||
const placeholder =
|
const placeholder =
|
||||||
|
|
@ -66,7 +73,7 @@ customElements.define(
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
if (!(e instanceof CustomEvent)) e.stopImmediatePropagation();
|
||||||
},
|
},
|
||||||
true,
|
{ capture: true, signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.textContent = "";
|
this.textContent = "";
|
||||||
|
|
@ -93,70 +100,83 @@ customElements.define(
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
search.addEventListener("input", () => {
|
search.addEventListener(
|
||||||
const q = search.value.toLowerCase();
|
"input",
|
||||||
options.classList.toggle("hidden", !q);
|
() => {
|
||||||
options.querySelector(".ss-active")?.classList.remove("ss-active");
|
const q = search.value.toLowerCase();
|
||||||
buttons.forEach((btn) => {
|
options.classList.toggle("hidden", !q);
|
||||||
btn.style.display = btn.textContent.toLowerCase().includes(q)
|
options.querySelector(".ss-active")?.classList.remove("ss-active");
|
||||||
? ""
|
buttons.forEach((btn) => {
|
||||||
: "none";
|
btn.style.display = btn.textContent.toLowerCase().includes(q)
|
||||||
});
|
? ""
|
||||||
updateExpanded();
|
: "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
search.addEventListener("keydown", (e) => {
|
|
||||||
const visible = buttons.filter(
|
|
||||||
(b) =>
|
|
||||||
b.style.display !== "none" && !options.classList.contains("hidden"),
|
|
||||||
);
|
|
||||||
if (!visible.length) return;
|
|
||||||
|
|
||||||
const active = options.querySelector(".ss-active");
|
|
||||||
let idx = active ? visible.indexOf(active) : -1;
|
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (active) active.classList.remove("ss-active");
|
|
||||||
idx = (idx + 1) % visible.length;
|
|
||||||
visible[idx].classList.add("ss-active");
|
|
||||||
visible[idx].scrollIntoView({ block: "nearest" });
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (active) active.classList.remove("ss-active");
|
|
||||||
idx = idx <= 0 ? visible.length - 1 : idx - 1;
|
|
||||||
visible[idx].classList.add("ss-active");
|
|
||||||
visible[idx].scrollIntoView({ block: "nearest" });
|
|
||||||
} else if (e.key === "Enter" && active) {
|
|
||||||
e.preventDefault();
|
|
||||||
active.click();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
options.classList.add("hidden");
|
|
||||||
updateExpanded();
|
updateExpanded();
|
||||||
}
|
},
|
||||||
});
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
options.addEventListener("click", (e) => {
|
search.addEventListener(
|
||||||
const btn = e.target.closest("button");
|
"keydown",
|
||||||
if (!btn || !options.contains(btn)) return;
|
(e) => {
|
||||||
|
const visible = buttons.filter(
|
||||||
|
(b) =>
|
||||||
|
b.style.display !== "none" &&
|
||||||
|
!options.classList.contains("hidden"),
|
||||||
|
);
|
||||||
|
if (!visible.length) return;
|
||||||
|
|
||||||
hidden.value = btn.value;
|
const active = options.querySelector(".ss-active");
|
||||||
display.textContent = btn.dataset.display;
|
let idx = active ? visible.indexOf(active) : -1;
|
||||||
searchWrap.classList.add("hidden");
|
|
||||||
selectedWrap.classList.remove("hidden");
|
|
||||||
updateExpanded();
|
|
||||||
|
|
||||||
this.dispatchEvent(
|
if (e.key === "ArrowDown") {
|
||||||
new CustomEvent("change", {
|
e.preventDefault();
|
||||||
detail: {
|
if (active) active.classList.remove("ss-active");
|
||||||
value: btn.value,
|
idx = (idx + 1) % visible.length;
|
||||||
display: btn.dataset.display,
|
visible[idx].classList.add("ss-active");
|
||||||
data: { ...btn.dataset },
|
visible[idx].scrollIntoView({ block: "nearest" });
|
||||||
},
|
} else if (e.key === "ArrowUp") {
|
||||||
bubbles: true,
|
e.preventDefault();
|
||||||
}),
|
if (active) active.classList.remove("ss-active");
|
||||||
);
|
idx = idx <= 0 ? visible.length - 1 : idx - 1;
|
||||||
});
|
visible[idx].classList.add("ss-active");
|
||||||
|
visible[idx].scrollIntoView({ block: "nearest" });
|
||||||
|
} else if (e.key === "Enter" && active) {
|
||||||
|
e.preventDefault();
|
||||||
|
active.click();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
options.classList.add("hidden");
|
||||||
|
updateExpanded();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
options.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn || !options.contains(btn)) return;
|
||||||
|
|
||||||
|
hidden.value = btn.value;
|
||||||
|
display.textContent = btn.dataset.display;
|
||||||
|
searchWrap.classList.add("hidden");
|
||||||
|
selectedWrap.classList.remove("hidden");
|
||||||
|
updateExpanded();
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("change", {
|
||||||
|
detail: {
|
||||||
|
value: btn.value,
|
||||||
|
display: btn.dataset.display,
|
||||||
|
data: { ...btn.dataset },
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
const doClear = () => {
|
const doClear = () => {
|
||||||
hidden.value = "";
|
hidden.value = "";
|
||||||
|
|
@ -171,7 +191,7 @@ customElements.define(
|
||||||
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
|
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
selectedWrap.addEventListener("click", doClear);
|
selectedWrap.addEventListener("click", doClear, { signal });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -84,15 +84,24 @@ customElements.define(
|
||||||
const entries = raw.split(",").map((e) => e.split(":"));
|
const entries = raw.split(",").map((e) => e.split(":"));
|
||||||
const sr = document.createElement("div");
|
const sr = document.createElement("div");
|
||||||
sr.className = "sr-only";
|
sr.className = "sr-only";
|
||||||
sr.innerHTML =
|
const table = document.createElement("table");
|
||||||
"<table><caption>Coffee origins by country</caption><thead><tr><th>Country</th><th>Count</th></tr></thead><tbody>" +
|
const caption = table.createCaption();
|
||||||
entries
|
caption.textContent = "Coffee origins by country";
|
||||||
.map(
|
const thead = table.createTHead();
|
||||||
([code, count]) =>
|
const headRow = thead.insertRow();
|
||||||
`<tr><td>${code.toUpperCase()}</td><td>${count}</td></tr>`,
|
const th1 = document.createElement("th");
|
||||||
)
|
th1.textContent = "Country";
|
||||||
.join("") +
|
const th2 = document.createElement("th");
|
||||||
"</tbody></table>";
|
th2.textContent = "Count";
|
||||||
|
headRow.appendChild(th1);
|
||||||
|
headRow.appendChild(th2);
|
||||||
|
const tbody = table.createTBody();
|
||||||
|
for (const [code, count] of entries) {
|
||||||
|
const row = tbody.insertRow();
|
||||||
|
row.insertCell().textContent = code.toUpperCase();
|
||||||
|
row.insertCell().textContent = count;
|
||||||
|
}
|
||||||
|
sr.appendChild(table);
|
||||||
this.appendChild(sr);
|
this.appendChild(sr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,9 +119,9 @@ customElements.define(
|
||||||
|
|
||||||
const styles = getComputedStyle(document.documentElement);
|
const styles = getComputedStyle(document.documentElement);
|
||||||
const rgb =
|
const rgb =
|
||||||
styles.getPropertyValue("--highlight-rgb").trim() || "185, 28, 28";
|
styles.getPropertyValue("--highlight-rgb").trim() || "194, 65, 12";
|
||||||
const borderColor =
|
const borderColor =
|
||||||
styles.getPropertyValue("--text-muted").trim() || "#9ca3af";
|
styles.getPropertyValue("--text-muted").trim() || "#78716c";
|
||||||
const surfaceAlt =
|
const surfaceAlt =
|
||||||
styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4";
|
styles.getPropertyValue("--surface-alt").trim() || "#f5f5f4";
|
||||||
const borderMuted =
|
const borderMuted =
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas.
|
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas.
|
||||||
* Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). */
|
* Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos).
|
||||||
|
* Caps the longest side to 1920px to avoid exceeding the request body limit. */
|
||||||
const imageToJpegDataUrl = async (file) => {
|
const imageToJpegDataUrl = async (file) => {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const maxDim = 1920;
|
||||||
|
let { width, height } = bitmap;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = bitmap.width;
|
canvas.width = width;
|
||||||
canvas.height = bitmap.height;
|
canvas.height = height;
|
||||||
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height);
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
return canvas.toDataURL("image/jpeg", 0.92);
|
return canvas.toDataURL("image/jpeg", 0.92);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
|
||||||
// 1. Get challenge from server
|
// 1. Get challenge from server
|
||||||
const startResponse = await fetch("/api/v1/webauthn/register/start", {
|
const startResponse = await fetch("/api/v1/webauthn/register/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token,
|
token,
|
||||||
|
|
@ -107,6 +108,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
|
||||||
// 3. Send credential to server
|
// 3. Send credential to server
|
||||||
const finishResponse = await fetch("/api/v1/webauthn/register/finish", {
|
const finishResponse = await fetch("/api/v1/webauthn/register/finish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
challenge_id,
|
challenge_id,
|
||||||
|
|
@ -128,7 +130,7 @@ const startPasskeyRegistration = async (token, displayName, passkeyName) => {
|
||||||
const startPasskeyAuthentication = async (queryParams) => {
|
const startPasskeyAuthentication = async (queryParams) => {
|
||||||
// 1. Get challenge from server
|
// 1. Get challenge from server
|
||||||
const url = `/api/v1/webauthn/auth/start${queryParams || ""}`;
|
const url = `/api/v1/webauthn/auth/start${queryParams || ""}`;
|
||||||
const startResponse = await fetch(url);
|
const startResponse = await fetch(url, { credentials: "same-origin" });
|
||||||
|
|
||||||
if (!startResponse.ok) {
|
if (!startResponse.ok) {
|
||||||
const status = startResponse.status;
|
const status = startResponse.status;
|
||||||
|
|
@ -146,6 +148,7 @@ const startPasskeyAuthentication = async (queryParams) => {
|
||||||
// 3. Send assertion to server
|
// 3. Send assertion to server
|
||||||
const finishResponse = await fetch("/api/v1/webauthn/auth/finish", {
|
const finishResponse = await fetch("/api/v1/webauthn/auth/finish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
challenge_id,
|
challenge_id,
|
||||||
|
|
@ -165,6 +168,7 @@ const addPasskey = async (name) => {
|
||||||
// 1. Get challenge from server
|
// 1. Get challenge from server
|
||||||
const startResponse = await fetch("/api/v1/webauthn/passkey/start", {
|
const startResponse = await fetch("/api/v1/webauthn/passkey/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
|
|
@ -184,6 +188,7 @@ const addPasskey = async (name) => {
|
||||||
// 3. Send credential to server
|
// 3. Send credential to server
|
||||||
const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", {
|
const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
challenge_id,
|
challenge_id,
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,20 @@
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<title>{% block title %}Brewlog{% endblock %}</title>
|
<title>{% block title %}Brewlog{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/static/css/styles.css?v={{ version_info.commit }}"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
id="favicon"
|
id="favicon"
|
||||||
type="image/svg+xml"
|
type="image/svg+xml"
|
||||||
href="/static/favicon-light.svg"
|
href="/static/favicon-light.svg?v={{ version_info.commit }}"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
href="/static/app-icon-192.png?v={{ version_info.commit }}"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="/static/app-icon-192.png" />
|
|
||||||
<link rel="manifest" href="/static/site.webmanifest" />
|
<link rel="manifest" href="/static/site.webmanifest" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Brewlog" />
|
<meta name="apple-mobile-web-app-title" content="Brewlog" />
|
||||||
<meta name="theme-color" content="#c2410c" />
|
<meta name="theme-color" content="#c2410c" />
|
||||||
|
|
@ -47,7 +53,8 @@
|
||||||
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
|
(!stored && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
document.getElementById("favicon").href = "/static/favicon-dark.svg";
|
document.getElementById("favicon").href =
|
||||||
|
"/static/favicon-dark.svg?v={{ version_info.commit }}";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -55,14 +62,30 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
||||||
></script>
|
></script>
|
||||||
<script defer src="/static/js/location.js"></script>
|
<script
|
||||||
<script defer src="/static/js/image-utils.js"></script>
|
defer
|
||||||
<script defer src="/static/js/components/photo-capture.js"></script>
|
src="/static/js/image-utils.js?v={{ version_info.commit }}"
|
||||||
<script defer src="/static/js/components/searchable-select.js"></script>
|
></script>
|
||||||
<script defer src="/static/js/components/chip-scroll.js"></script>
|
<script
|
||||||
<script defer src="/static/js/components/world-map.js"></script>
|
defer
|
||||||
<script defer src="/static/js/components/donut-chart.js"></script>
|
src="/static/js/components/photo-capture.js?v={{ version_info.commit }}"
|
||||||
<script defer src="/static/js/components/image-upload.js"></script>
|
></script>
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/components/searchable-select.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/components/chip-scroll.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/components/world-map.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/components/image-upload.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
const showToast = (msg, ms = 3000) => {
|
const showToast = (msg, ms = 3000) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
{% import "partials/detail_cards.html" as detail_cards %}
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% import "partials/forms/quick_notes.html" as quick_notes %}
|
{% import "partials/forms/quick_notes.html" as quick_notes %}
|
||||||
{% block title %}Brewlog · Add{% endblock %}
|
{% block title %}Brewlog · Add{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/location.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section
|
<section
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · Admin{% endblock %}
|
{% block title %}Brewlog · Admin{% endblock %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script defer src="/static/js/webauthn.js"></script>
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
{% block title %}
|
{% block title %}
|
||||||
Brewlog · Check In
|
Brewlog · Check In
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/location.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section
|
<section
|
||||||
id="checkin-root"
|
id="checkin-root"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · Login{% endblock %}
|
{% block title %}Brewlog · Login{% endblock %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script defer src="/static/js/webauthn.js"></script>
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
{% block title %}Brewlog · Register{% endblock %}
|
{% block title %}Brewlog · Register{% endblock %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script defer src="/static/js/webauthn.js"></script>
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="/static/js/components/donut-chart.js?v={{ version_info.commit }}"
|
||||||
|
></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod pages;
|
||||||
pub mod roasters_api;
|
pub mod roasters_api;
|
||||||
pub mod roasts_api;
|
pub mod roasts_api;
|
||||||
pub mod scan_api;
|
pub mod scan_api;
|
||||||
|
pub mod static_assets;
|
||||||
pub mod stats_api;
|
pub mod stats_api;
|
||||||
pub mod test_macros;
|
pub mod test_macros;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use reqwest::redirect::Policy;
|
use reqwest::redirect::Policy;
|
||||||
|
|
||||||
use crate::helpers::{
|
use crate::helpers::{
|
||||||
assert_full_page, create_default_bag, create_default_roast, create_default_roaster,
|
assert_full_page, create_default_bag, create_default_brew, create_default_roast,
|
||||||
create_session, spawn_app, spawn_app_with_auth,
|
create_default_roaster, create_session, spawn_app, spawn_app_with_auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -284,3 +284,25 @@ async fn scan_redirect_returns_permanent_redirect() {
|
||||||
.and_then(|v| v.to_str().ok());
|
.and_then(|v| v.to_str().ok());
|
||||||
assert_eq!(location, Some("/"));
|
assert_eq!(location, Some("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn homepage_contains_chip_scroll_with_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
||||||
|
let _brew = create_default_brew(&app).await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert!(
|
||||||
|
body.contains("<chip-scroll"),
|
||||||
|
"Homepage should contain chip-scroll component when data exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
98
tests/server/static_assets.rs
Normal file
98
tests/server/static_assets.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
use crate::helpers::spawn_app;
|
||||||
|
|
||||||
|
macro_rules! define_static_asset_test {
|
||||||
|
($name:ident, $path:expr, $content_type:expr) => {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn $name() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url($path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some($content_type),
|
||||||
|
"Wrong content-type for {}",
|
||||||
|
$path
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("cache-control")
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some("public, max-age=604800"),
|
||||||
|
"Wrong cache-control for {}",
|
||||||
|
$path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
define_static_asset_test!(
|
||||||
|
styles_css,
|
||||||
|
"/static/css/styles.css",
|
||||||
|
"text/css; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
webauthn_js,
|
||||||
|
"/static/js/webauthn.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
location_js,
|
||||||
|
"/static/js/location.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
image_utils_js,
|
||||||
|
"/static/js/image-utils.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
photo_capture_js,
|
||||||
|
"/static/js/components/photo-capture.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
searchable_select_js,
|
||||||
|
"/static/js/components/searchable-select.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
chip_scroll_js,
|
||||||
|
"/static/js/components/chip-scroll.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
world_map_js,
|
||||||
|
"/static/js/components/world-map.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
donut_chart_js,
|
||||||
|
"/static/js/components/donut-chart.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(
|
||||||
|
image_upload_js,
|
||||||
|
"/static/js/components/image-upload.js",
|
||||||
|
"application/javascript; charset=utf-8"
|
||||||
|
);
|
||||||
|
define_static_asset_test!(favicon_light, "/static/favicon-light.svg", "image/svg+xml");
|
||||||
|
define_static_asset_test!(favicon_dark, "/static/favicon-dark.svg", "image/svg+xml");
|
||||||
|
define_static_asset_test!(og_image, "/static/og-image.png", "image/png");
|
||||||
|
define_static_asset_test!(app_icon_192, "/static/app-icon-192.png", "image/png");
|
||||||
|
define_static_asset_test!(app_icon_512, "/static/app-icon-512.png", "image/png");
|
||||||
|
define_static_asset_test!(
|
||||||
|
site_webmanifest,
|
||||||
|
"/static/site.webmanifest",
|
||||||
|
"application/manifest+json; charset=utf-8"
|
||||||
|
);
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::helpers::{
|
use crate::helpers::{
|
||||||
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_cafe,
|
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_brew,
|
||||||
create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth,
|
create_default_cafe, create_default_roast, create_default_roaster, spawn_app,
|
||||||
|
spawn_app_with_auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -167,6 +168,69 @@ async fn recompute_stats_reflects_created_data() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_contains_world_map_with_geo_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let roaster = create_default_roaster(&app).await;
|
||||||
|
let _roast = create_default_roast(&app, roaster.id).await;
|
||||||
|
|
||||||
|
// Populate the cache so the stats page has data
|
||||||
|
let recompute = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to recompute");
|
||||||
|
assert_eq!(recompute.status(), 200);
|
||||||
|
|
||||||
|
// Fetch the roasts tab fragment which contains the world-map
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats?type=roasts"))
|
||||||
|
.header("datastar-request", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert!(
|
||||||
|
body.contains("<world-map"),
|
||||||
|
"Roasts tab should contain world-map component"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_contains_donut_chart_with_brew_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let _brew = create_default_brew(&app).await;
|
||||||
|
|
||||||
|
// Populate the cache
|
||||||
|
let recompute = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to recompute");
|
||||||
|
assert_eq!(recompute.status(), 200);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert!(
|
||||||
|
body.contains("<donut-chart"),
|
||||||
|
"Stats page should contain donut-chart component when brew data exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stats_page_loads_after_recompute() {
|
async fn stats_page_loads_after_recompute() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue