security: externalize astro scripts and styles

This commit is contained in:
ik
2026-09-13 15:14:21 +07:00
parent cd319a32c2
commit 14958be302
7 changed files with 29 additions and 22 deletions
+5
View File
@@ -7,4 +7,9 @@ export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
server: { host: true, port: 4321 },
vite: {
// Keep executable scripts and compiled component styles in same-origin
// assets so production CSP does not need broad inline allowances.
build: { assetsInlineLimit: 0 },
},
});
+14 -9
View File
@@ -7,6 +7,7 @@ const state = Astro.url.searchParams.get("state");
const reportId = Astro.url.searchParams.get("report_id");
---
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
<span id="report-state" data-state={state ?? ""} hidden></span>
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
@@ -20,13 +21,15 @@ const reportId = Astro.url.searchParams.get("report_id");
<fieldset><legend>Главное <span>обязательно</span></legend><div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
</form>}
<script is:inline define:vars={{ state }}>
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
<script>
const form = document.querySelector<HTMLFormElement>(".report-form");
const state = document.querySelector<HTMLElement>("#report-state")?.dataset.state ?? "";
const key = "rf4-report-draft";
// A05: Safe sessionStorage access with error handling
const safeStorage = {
getItem: (k) => { try { return sessionStorage.getItem(k); } catch { return null; } },
setItem: (k, v) => { try { sessionStorage.setItem(k, v); } catch {} },
removeItem: (k) => { try { sessionStorage.removeItem(k); } catch {} },
getItem: (k: string) => { try { return sessionStorage.getItem(k); } catch { return null; } },
setItem: (k: string, v: string) => { try { sessionStorage.setItem(k, v); } catch {} },
removeItem: (k: string) => { try { sessionStorage.removeItem(k); } catch {} },
};
// A05: Restore draft on all error states that don't destroy the submission
const recoverableStates = ["create_error", "rate_limited", "server_error", "timeout"];
@@ -35,17 +38,19 @@ const reportId = Astro.url.searchParams.get("report_id");
const draft = JSON.parse(safeStorage.getItem(key) || "{}");
for (const [name, value] of Object.entries(draft)) {
const field = form.elements.namedItem(name);
if (field && "value" in field) field.value = value;
if (field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) {
field.value = String(value);
}
}
} catch {}
document.querySelector("#form-error")?.focus();
document.querySelector<HTMLElement>("#form-error")?.focus();
}
if (state === "sent" || state === "screenshot_sent") safeStorage.removeItem(key);
form?.addEventListener("submit", () => {
const btn = form.querySelector("button[type=submit]");
const btn = form.querySelector<HTMLButtonElement>("button[type=submit]");
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
// A05: Save draft before submit, handle sessionStorage unavailable
const draft = {};
const draft: Record<string, string> = {};
try {
for (const [name, value] of new FormData(form)) {
if (typeof value === "string" && name !== "website") draft[name] = value;
+1 -1
View File
@@ -47,7 +47,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
</section>
</>}
</Layout>
<script is:inline>
<script>
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
button.addEventListener("click", async () => {
const value = button.dataset.copyCoordinates || "";