From fb0fd5b7d3602d6fb1948e108a35f92dc733e5ab Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:35:22 +0700 Subject: [PATCH 01/16] Add explicit analytics consent controller --- landing/consent.js | 111 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 landing/consent.js diff --git a/landing/consent.js b/landing/consent.js new file mode 100644 index 000000000..a33e08e4c --- /dev/null +++ b/landing/consent.js @@ -0,0 +1,111 @@ +(() => { + const STORAGE_KEY = 'arsas_analytics_consent_v1'; + const EVENT_NAME = 'arsas:consent'; + const dntEnabled = navigator.doNotTrack === '1' || window.doNotTrack === '1'; + const banner = document.querySelector('[data-consent-banner]'); + const status = document.querySelector('[data-consent-status]'); + const acceptButtons = document.querySelectorAll('[data-consent-accept]'); + const rejectButtons = document.querySelectorAll('[data-consent-reject]'); + const manageButtons = document.querySelectorAll('[data-consent-manage]'); + + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function gtag() { + window.dataLayer.push(arguments); + }; + window.gtag('consent', 'default', { + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + wait_for_update: 500 + }); + + const readPreference = () => { + if (dntEnabled) return 'denied'; + try { + const value = window.localStorage.getItem(STORAGE_KEY); + return value === 'granted' || value === 'denied' ? value : 'unset'; + } catch { + return 'unset'; + } + }; + + const setStatus = preference => { + if (!status) return; + const isId = document.documentElement.lang === 'id'; + if (dntEnabled) { + status.textContent = isId ? 'Do Not Track aktif; analitik tetap nonaktif.' : 'Do Not Track is active; analytics stays disabled.'; + } else if (preference === 'granted') { + status.textContent = isId ? 'Analitik opsional diizinkan.' : 'Optional analytics is allowed.'; + } else if (preference === 'denied') { + status.textContent = isId ? 'Analitik opsional ditolak.' : 'Optional analytics is declined.'; + } else { + status.textContent = isId ? 'Belum ada pilihan analitik.' : 'No analytics preference has been saved.'; + } + }; + + const dispatch = (preference, source) => { + document.documentElement.dataset.analyticsConsent = preference; + setStatus(preference); + window.dispatchEvent(new CustomEvent(EVENT_NAME, { + detail: { analytics: preference, source, doNotTrack: dntEnabled } + })); + }; + + const showBanner = focus = false => { + if (!(banner instanceof HTMLElement)) return; + banner.hidden = false; + document.body.classList.add('consent-open'); + if (focus) { + const first = banner.querySelector('button'); + if (first instanceof HTMLButtonElement) first.focus(); + } + }; + + const hideBanner = () => { + if (!(banner instanceof HTMLElement)) return; + banner.hidden = true; + document.body.classList.remove('consent-open'); + }; + + const save = preference => { + const effective = dntEnabled ? 'denied' : preference; + try { + window.localStorage.setItem(STORAGE_KEY, effective); + } catch { + // The effective preference still applies for this page when storage is unavailable. + } + window.gtag('consent', 'update', { + analytics_storage: effective === 'granted' ? 'granted' : 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }); + hideBanner(); + dispatch(effective, 'user-choice'); + }; + + acceptButtons.forEach(button => button.addEventListener('click', () => save('granted'))); + rejectButtons.forEach(button => button.addEventListener('click', () => save('denied'))); + manageButtons.forEach(button => button.addEventListener('click', () => showBanner(true))); + + if (banner instanceof HTMLElement) { + banner.addEventListener('keydown', event => { + if (event.key === 'Escape' && readPreference() !== 'unset') hideBanner(); + }); + } + + const initial = readPreference(); + setStatus(initial); + if (initial === 'unset' && !dntEnabled) showBanner(false); + dispatch(initial === 'granted' ? 'granted' : 'denied', 'initial'); + + window.ARSASConsent = Object.freeze({ + storageKey: STORAGE_KEY, + doNotTrack: dntEnabled, + get: readPreference, + manage: () => showBanner(true), + grant: () => save('granted'), + deny: () => save('denied') + }); +})(); From 195dcaa4e88d76c1f97d431efd8f324004a6497b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:35:59 +0700 Subject: [PATCH 02/16] Add English privacy policy source --- landing/privacy-source/privacy.en.html.tmpl | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 landing/privacy-source/privacy.en.html.tmpl diff --git a/landing/privacy-source/privacy.en.html.tmpl b/landing/privacy-source/privacy.en.html.tmpl new file mode 100644 index 000000000..1c0bc544a --- /dev/null +++ b/landing/privacy-source/privacy.en.html.tmpl @@ -0,0 +1,47 @@ + + + + + + Privacy and Optional Analytics | ARSAS + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Privacy · optional analytics · user choice

Measurement is optional and disabled until you allow it.

ARSAS uses a small, consent-controlled measurement layer to understand product-page usage, download intent, language demand, broken routes and Core Web Vitals. The website remains fully usable when analytics is declined.

Baca dalam Bahasa Indonesia
+ +
What may be measured

Aggregate product-site signals

  • Page views and page groups.
  • Installer, portable ZIP and checksum link clicks.
  • English or Indonesian page usage and language switches.
  • 404 paths and referring pages.
  • LCP, CLS, INP and diagnostic TTFB values.
What ARSAS does not add

No engineering or device data

  • No relay IP address, SCL content, signal value or project file.
  • No form content, account identifier or ARSAS application telemetry.
  • No Google Signals, advertising storage or ad-personalization signals.
  • No custom User-ID and no attempt to identify an individual engineer.
+ +
Consent behavior

Google Analytics loads only after approval.

The default consent state is denied. A local preference named arsas_analytics_consent_v1 stores only granted or denied. Declining analytics does not block downloads, documentation or any product page. Browser Do Not Track keeps analytics disabled even if a previous preference was granted.

Processors and hosting

GitHub hosts the site; Google processes optional analytics.

GitHub Pages serves the public files and may process normal web-request metadata under GitHub’s own terms. When consent is granted, Google Analytics receives the aggregate event fields described above under Google’s terms. Private weekly reports are stored as GitHub Actions artifacts rather than being published on this website.

+ +
Retention policy

Short operational retention

The ARSAS project policy is a two-month event-data retention window for GA4 and a 90-day maximum for private aggregate workflow artifacts. Analytics must remain disabled until the GA4 property is configured consistently with this policy.

Your control

Change the preference at any time.

Use the analytics-preferences control in the footer or the button below. Revoking consent prevents new analytics events from being sent from subsequent interactions and page loads.

+ +
Questions or concerns

Report a privacy or measurement issue with evidence.

Describe the page, browser, observed request or consent behavior. Do not attach confidential project, IED or customer information.

+
+ {{> footer}} + + From 6dc7ee3a8caa9e39929c969bd350b93258d07334 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:36:29 +0700 Subject: [PATCH 03/16] Add Indonesian privacy policy source --- landing/privacy-source/privacy.id.html.tmpl | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 landing/privacy-source/privacy.id.html.tmpl diff --git a/landing/privacy-source/privacy.id.html.tmpl b/landing/privacy-source/privacy.id.html.tmpl new file mode 100644 index 000000000..167f9bb73 --- /dev/null +++ b/landing/privacy-source/privacy.id.html.tmpl @@ -0,0 +1,47 @@ + + + + + + Privasi dan Analitik Opsional | ARSAS + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Privasi · analitik opsional · pilihan pengguna

Measurement bersifat opsional dan nonaktif sampai Anda mengizinkannya.

ARSAS memakai measurement kecil berbasis persetujuan untuk memahami penggunaan halaman produk, niat download, kebutuhan bahasa, broken route dan Core Web Vitals. Website tetap berfungsi penuh saat analitik ditolak.

Read in English
+ +
Yang dapat diukur

Sinyal agregat website produk

  • Page view dan kelompok halaman.
  • Klik link Installer, Portable ZIP dan checksum.
  • Pemakaian halaman English atau Indonesia serta perpindahan bahasa.
  • Path 404 dan halaman perujuk.
  • Nilai LCP, CLS, INP dan diagnostic TTFB.
Yang tidak ditambahkan ARSAS

Tidak ada data engineering atau device

  • Tidak ada IP relay, isi SCL, nilai sinyal atau file proyek.
  • Tidak ada isi form, account identifier atau telemetry aplikasi ARSAS.
  • Tidak ada Google Signals, advertising storage atau ad-personalization signals.
  • Tidak ada custom User-ID dan tidak ada upaya mengidentifikasi engineer tertentu.
+ +
Perilaku persetujuan

Google Analytics hanya dimuat setelah disetujui.

Status awal consent adalah denied. Preferensi lokal bernama arsas_analytics_consent_v1 hanya menyimpan nilai granted atau denied. Menolak analitik tidak memblokir download, dokumentasi atau halaman produk. Browser Do Not Track mempertahankan analitik tetap nonaktif walaupun preferensi sebelumnya pernah diberikan.

Processor dan hosting

GitHub meng-host website; Google memproses analitik opsional.

GitHub Pages melayani file publik dan dapat memproses metadata request web normal sesuai ketentuan GitHub. Setelah consent diberikan, Google Analytics menerima field event agregat yang dijelaskan di atas sesuai ketentuan Google. Laporan mingguan privat disimpan sebagai artifact GitHub Actions dan tidak dipublikasikan di website.

+ +
Kebijakan retensi

Retensi operasional singkat

Kebijakan proyek ARSAS adalah retensi event-data GA4 selama dua bulan dan maksimal 90 hari untuk artifact workflow agregat privat. Analitik harus tetap nonaktif sampai property GA4 dikonfigurasi konsisten dengan kebijakan ini.

Kontrol Anda

Preferensi dapat diubah kapan saja.

Gunakan kontrol preferensi analitik di footer atau tombol berikut. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.

+ +
Pertanyaan atau keberatan

Laporkan masalah privasi atau measurement dengan evidence.

Jelaskan halaman, browser, request atau perilaku consent yang terlihat. Jangan melampirkan informasi rahasia proyek, IED atau customer.

+
+ {{> footer}} + + From 2a954049a052e1f476e0dff95a2f1a680934244f Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:36:50 +0700 Subject: [PATCH 04/16] Generate bilingual privacy pages outside search sitemap --- scripts/generate-privacy-pages.py | 92 +++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 scripts/generate-privacy-pages.py diff --git a/scripts/generate-privacy-pages.py b/scripts/generate-privacy-pages.py new file mode 100644 index 000000000..437e35f0f --- /dev/null +++ b/scripts/generate-privacy-pages.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Generate bilingual noindex privacy pages with shared ARSAS chrome.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import re +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BUILDER_PATH = ROOT / "scripts" / "build-product-site.py" +SOURCE = ROOT / "landing" / "privacy-source" +ANALYTICS_SCRIPT = re.compile( + r'\s*]*>\s*', + re.IGNORECASE, +) + + +def load_builder(): + spec = importlib.util.spec_from_file_location("arsas_product_builder", BUILDER_PATH) + if spec is None or spec.loader is None: + raise SystemExit("Cannot load product website builder") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", default=str(ROOT / "_site")) + parser.add_argument("--release-evidence", default=str(ROOT / "landing" / "latest.json")) + args = parser.parse_args() + + output = Path(args.output).resolve() + evidence_path = Path(args.release_evidence).resolve() + if not output.is_dir() or not (output / "build-info.json").is_file(): + raise SystemExit("Build the product website before generating privacy pages") + + builder = load_builder() + config = builder.read_config() + version = builder.read_version() + evidence, notes = builder.read_release_data(evidence_path) + values = builder.token_values(config, version, evidence, notes) + width, height = builder.icon_dimensions() + icon_size = f"{width}x{height}" + root = str(config["product"]["canonicalRoot"]) + + pages = ( + ( + SOURCE / "privacy.en.html.tmpl", + output / "privacy.html", + {"en": "privacy.html", "id": "privasi.html", "x-default": "privacy.html"}, + ), + ( + SOURCE / "privacy.id.html.tmpl", + output / "privasi.html", + {"en": "privacy.html", "id": "privasi.html", "x-default": "privacy.html"}, + ), + ) + + for source, target, alternates in pages: + if not source.is_file(): + raise SystemExit(f"Missing privacy source: {source}") + rendered = builder.render(source.read_text(encoding="utf-8"), values, icon_size) + rendered = builder.inject_alternate_links(rendered, {"template": source.name, "alternates": alternates}, root) + rendered = ANALYTICS_SCRIPT.sub("", rendered) + if "__ARSAS_GA4_MEASUREMENT_ID__" in rendered: + raise SystemExit(f"Privacy page still contains a measurement placeholder: {target.name}") + target.write_text(rendered, encoding="utf-8") + + build_info_path = output / "build-info.json" + build_info = json.loads(build_info_path.read_text(encoding="utf-8")) + build_info["privacyPages"] = ["privacy.html", "privasi.html"] + build_info["privacy"] = { + "indexing": "noindex,follow", + "consentRequired": True, + "defaultAnalyticsConsent": "denied", + "preferenceStorage": "localStorage", + "preferenceKey": "arsas_analytics_consent_v1", + } + build_info_path.write_text(json.dumps(build_info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + shutil.rmtree(output / "privacy-source", ignore_errors=True) + print("Generated privacy.html and privasi.html with noindex, consent controls and shared ARSAS chrome.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From afc1d8540883f6b41e404fa0f48de732b961493c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:37:08 +0700 Subject: [PATCH 05/16] Stamp deployable website with source attestation --- scripts/stamp-site-build.py | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 scripts/stamp-site-build.py diff --git a/scripts/stamp-site-build.py b/scripts/stamp-site-build.py new file mode 100644 index 000000000..f6e2dd2fa --- /dev/null +++ b/scripts/stamp-site-build.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Stamp build-info.json with immutable deployment provenance.""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import datetime +from pathlib import Path + +SHA_PATTERN = re.compile(r"[0-9a-f]{40}", re.IGNORECASE) + + +def iso_timestamp(value: str) -> str: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise SystemExit(f"Invalid commit timestamp: {value}") from exc + return parsed.isoformat() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + parser.add_argument("--source-commit", required=True) + parser.add_argument("--source-ref", required=True) + parser.add_argument("--commit-timestamp", required=True) + parser.add_argument("--workflow-run-id", required=True) + parser.add_argument("--workflow-run-attempt", default="1") + args = parser.parse_args() + + site = Path(args.site).resolve() + path = site / "build-info.json" + if not path.is_file(): + raise SystemExit(f"Missing build-info.json: {path}") + source_commit = args.source_commit.strip().lower() + if not SHA_PATTERN.fullmatch(source_commit): + raise SystemExit("source commit must be a full 40-character Git SHA") + if not args.source_ref.strip(): + raise SystemExit("source ref is required") + commit_timestamp = iso_timestamp(args.commit_timestamp.strip()) + if not str(args.workflow_run_id).strip(): + raise SystemExit("workflow run ID is required") + + payload = json.loads(path.read_text(encoding="utf-8")) + payload["sourceCommit"] = source_commit + payload["sourceRef"] = args.source_ref.strip() + payload["buildTimestampUtc"] = commit_timestamp + payload["workflowRunId"] = str(args.workflow_run_id).strip() + payload["workflowRunAttempt"] = str(args.workflow_run_attempt).strip() or "1" + payload["deploymentAttestation"] = { + "provider": "github-pages", + "sourceCommit": source_commit, + "sourceRef": args.source_ref.strip(), + "commitTimestampUtc": commit_timestamp, + "workflowRunId": str(args.workflow_run_id).strip(), + "workflowRunAttempt": str(args.workflow_run_attempt).strip() or "1", + } + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Stamped ARSAS website build with source commit {source_commit}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a472c1f506b655c2fcc95ea7182fee47ed07a852 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:37:55 +0700 Subject: [PATCH 06/16] Verify public Pages commit and privacy readiness --- scripts/verify-pages-deployment.py | 174 +++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 scripts/verify-pages-deployment.py diff --git a/scripts/verify-pages-deployment.py b/scripts/verify-pages-deployment.py new file mode 100644 index 000000000..2a6c4f9ae --- /dev/null +++ b/scripts/verify-pages-deployment.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Verify that public GitHub Pages serves the expected ARSAS build.""" + +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urljoin +from urllib.request import Request, urlopen + + +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "enabled"}: + return True + if normalized in {"0", "false", "no", "disabled"}: + return False + raise argparse.ArgumentTypeError("expected true or false") + + +def fetch(url: str, timeout: int = 20) -> tuple[int, str, dict[str, str]]: + request = Request( + url, + headers={ + "User-Agent": "ARSAS-Pages-Attestation/1.0", + "Accept": "application/json,text/html;q=0.9,*/*;q=0.8", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + }, + ) + try: + with urlopen(request, timeout=timeout) as response: + return response.status, response.read().decode("utf-8", errors="replace"), dict(response.headers.items()) + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace"), dict(exc.headers.items()) + except URLError as exc: + raise RuntimeError(str(exc.reason)) from exc + + +def check_once(base_url: str, source_commit: str, stable_version: str, measurement_enabled: bool, nonce: str) -> tuple[list[str], dict[str, object]]: + errors: list[str] = [] + evidence: dict[str, object] = {} + info_url = urljoin(base_url, "build-info.json") + "?" + urlencode({"attest": source_commit, "n": nonce}) + status, body, headers = fetch(info_url) + evidence["buildInfoUrl"] = info_url + evidence["buildInfoStatus"] = status + evidence["buildInfoHeaders"] = {key: headers[key] for key in headers if key.lower() in {"etag", "last-modified", "cache-control", "content-type"}} + if status != 200: + return [f"build-info.json returned HTTP {status}"], evidence + try: + info = json.loads(body) + except json.JSONDecodeError as exc: + return [f"build-info.json is not valid JSON: {exc}"], evidence + evidence["publicBuildInfo"] = info + + if info.get("sourceCommit") != source_commit: + errors.append(f"public sourceCommit is {info.get('sourceCommit')!r}, expected {source_commit}") + attestation = info.get("deploymentAttestation") + if not isinstance(attestation, dict) or attestation.get("sourceCommit") != source_commit: + errors.append("deploymentAttestation does not match the expected source commit") + if info.get("stableReleaseVersion") != stable_version: + errors.append(f"public stable release is {info.get('stableReleaseVersion')!r}, expected {stable_version}") + if not info.get("workflowRunId") or not info.get("buildTimestampUtc"): + errors.append("public build metadata is missing workflowRunId or buildTimestampUtc") + + measurement = info.get("measurement") + if not isinstance(measurement, dict): + errors.append("public build is missing measurement metadata") + else: + if measurement.get("enabled") is not measurement_enabled: + errors.append(f"public measurement enabled={measurement.get('enabled')!r}, expected {measurement_enabled}") + if measurement.get("consentRequired") is not True or measurement.get("defaultConsent") != "denied": + errors.append("public measurement metadata does not require denied-by-default consent") + if measurement.get("advertisingSignals") is not False: + errors.append("public measurement metadata must keep advertising signals disabled") + + privacy_pages = info.get("privacyPages") + if privacy_pages != ["privacy.html", "privasi.html"]: + errors.append("public build does not declare both bilingual privacy pages") + privacy = info.get("privacy") + if not isinstance(privacy, dict) or privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied": + errors.append("public privacy metadata is incomplete") + + for relative in ("privacy.html", "privasi.html"): + url = urljoin(base_url, relative) + "?" + urlencode({"attest": source_commit}) + page_status, page, _ = fetch(url) + evidence[f"{relative}Status"] = page_status + if page_status != 200: + errors.append(f"{relative} returned HTTP {page_status}") + continue + for required in ('name="robots" content="noindex,follow"', 'src="consent.js"', 'data-consent-manage', 'data-consent-status'): + if required not in page: + errors.append(f"{relative} is missing {required}") + if 'id="arsas-analytics"' in page: + errors.append(f"{relative} must not load the analytics client") + + home_url = base_url + "?" + urlencode({"attest": source_commit}) + home_status, home, _ = fetch(home_url) + evidence["homeStatus"] = home_status + if home_status != 200: + errors.append(f"homepage returned HTTP {home_status}") + else: + for required in ('src="consent.js"', 'id="arsas-analytics"', 'data-consent-banner', 'assets/app-icon.png'): + if required not in home: + errors.append(f"homepage is missing {required}") + + return errors, evidence + + +def write_report(path: Path, success: bool, attempts: int, evidence: dict[str, object], errors: list[str]) -> None: + payload = { + "schemaVersion": 1, + "verifiedAtUtc": datetime.now(timezone.utc).isoformat(), + "success": success, + "attempts": attempts, + "errors": errors, + "evidence": evidence, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.with_suffix(".json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + lines = ["# ARSAS production deployment attestation", "", f"- Status: **{'PASS' if success else 'FAIL'}**", f"- Attempts: {attempts}"] + if errors: + lines.extend(["", "## Last observed errors", "", *[f"- {error}" for error in errors]]) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--stable-version", required=True) + parser.add_argument("--measurement-enabled", type=parse_bool, required=True) + parser.add_argument("--attempts", type=int, default=24) + parser.add_argument("--delay", type=float, default=10.0) + parser.add_argument("--output", default="_validation/production-attestation.md") + args = parser.parse_args() + + base_url = args.base_url.rstrip("/") + "/" + source_commit = args.source_commit.strip().lower() + last_errors: list[str] = [] + last_evidence: dict[str, object] = {} + used_attempts = 0 + for attempt in range(1, max(1, args.attempts) + 1): + used_attempts = attempt + try: + last_errors, last_evidence = check_once( + base_url, + source_commit, + args.stable_version.strip(), + args.measurement_enabled, + str(attempt), + ) + except Exception as exc: + last_errors = [f"network verification failed: {exc}"] + last_evidence = {} + if not last_errors: + write_report(Path(args.output), True, used_attempts, last_evidence, []) + print(f"Public ARSAS Pages attestation passed for {source_commit} after {used_attempts} attempt(s).") + return 0 + print(f"Attempt {attempt}/{args.attempts}: " + "; ".join(last_errors)) + if attempt < args.attempts: + time.sleep(max(0.0, args.delay)) + + write_report(Path(args.output), False, used_attempts, last_evidence, last_errors) + print("Public ARSAS Pages attestation failed: " + "; ".join(last_errors)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From eab939f3d85d5c62d11f4252c400e9ab08eab26c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:38:20 +0700 Subject: [PATCH 07/16] Add privacy routes and consent controls --- landing/partials/footer.html | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/landing/partials/footer.html b/landing/partials/footer.html index 5204177be..4c0d4cc5d 100644 --- a/landing/partials/footer.html +++ b/landing/partials/footer.html @@ -19,11 +19,27 @@ - + + + From bf97754d43df8fb0f1065ecf6faf03dc83a3bc09 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:41:14 +0700 Subject: [PATCH 08/16] Load analytics client only after consent --- landing/consent.js | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/landing/consent.js b/landing/consent.js index a33e08e4c..9ee979e33 100644 --- a/landing/consent.js +++ b/landing/consent.js @@ -4,9 +4,11 @@ const dntEnabled = navigator.doNotTrack === '1' || window.doNotTrack === '1'; const banner = document.querySelector('[data-consent-banner]'); const status = document.querySelector('[data-consent-status]'); + const analyticsConfig = document.getElementById('arsas-analytics'); const acceptButtons = document.querySelectorAll('[data-consent-accept]'); const rejectButtons = document.querySelectorAll('[data-consent-reject]'); const manageButtons = document.querySelectorAll('[data-consent-manage]'); + let analyticsLoaded = false; window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function gtag() { @@ -33,15 +35,10 @@ const setStatus = preference => { if (!status) return; const isId = document.documentElement.lang === 'id'; - if (dntEnabled) { - status.textContent = isId ? 'Do Not Track aktif; analitik tetap nonaktif.' : 'Do Not Track is active; analytics stays disabled.'; - } else if (preference === 'granted') { - status.textContent = isId ? 'Analitik opsional diizinkan.' : 'Optional analytics is allowed.'; - } else if (preference === 'denied') { - status.textContent = isId ? 'Analitik opsional ditolak.' : 'Optional analytics is declined.'; - } else { - status.textContent = isId ? 'Belum ada pilihan analitik.' : 'No analytics preference has been saved.'; - } + if (dntEnabled) status.textContent = isId ? 'Do Not Track aktif; analitik tetap nonaktif.' : 'Do Not Track is active; analytics stays disabled.'; + else if (preference === 'granted') status.textContent = isId ? 'Analitik opsional diizinkan.' : 'Optional analytics is allowed.'; + else if (preference === 'denied') status.textContent = isId ? 'Analitik opsional ditolak.' : 'Optional analytics is declined.'; + else status.textContent = isId ? 'Belum ada pilihan analitik.' : 'No analytics preference has been saved.'; }; const dispatch = (preference, source) => { @@ -52,7 +49,25 @@ })); }; - const showBanner = focus = false => { + const loadAnalytics = () => { + if (analyticsLoaded || dntEnabled || !(analyticsConfig instanceof HTMLScriptElement)) return; + const measurementId = analyticsConfig.dataset.measurementId || ''; + if (!/^G-[A-Z0-9]+$/.test(measurementId)) return; + analyticsLoaded = true; + window.gtag('consent', 'update', { + analytics_storage: 'granted', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }); + const client = document.createElement('script'); + client.src = 'analytics.js'; + client.async = true; + client.dataset.consentLoaded = 'true'; + document.head.appendChild(client); + }; + + const showBanner = focus => { if (!(banner instanceof HTMLElement)) return; banner.hidden = false; document.body.classList.add('consent-open'); @@ -69,20 +84,17 @@ }; const save = preference => { + const previous = readPreference(); const effective = dntEnabled ? 'denied' : preference; try { window.localStorage.setItem(STORAGE_KEY, effective); } catch { // The effective preference still applies for this page when storage is unavailable. } - window.gtag('consent', 'update', { - analytics_storage: effective === 'granted' ? 'granted' : 'denied', - ad_storage: 'denied', - ad_user_data: 'denied', - ad_personalization: 'denied' - }); hideBanner(); dispatch(effective, 'user-choice'); + if (effective === 'granted') loadAnalytics(); + else if (analyticsLoaded || previous === 'granted') window.location.reload(); }; acceptButtons.forEach(button => button.addEventListener('click', () => save('granted'))); @@ -99,6 +111,7 @@ setStatus(initial); if (initial === 'unset' && !dntEnabled) showBanner(false); dispatch(initial === 'granted' ? 'granted' : 'denied', 'initial'); + if (initial === 'granted') loadAnalytics(); window.ARSASConsent = Object.freeze({ storageKey: STORAGE_KEY, From 672484bf88bbdb06f858a33f3f3a30b6c04805a7 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 02:41:49 +0700 Subject: [PATCH 09/16] Make analytics configuration inert until consent --- landing/partials/footer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/partials/footer.html b/landing/partials/footer.html index 4c0d4cc5d..6b7d5cade 100644 --- a/landing/partials/footer.html +++ b/landing/partials/footer.html @@ -40,6 +40,6 @@