diff --git a/.github/workflows/deploy_promotion.yml b/.github/workflows/deploy_promotion.yml new file mode 100644 index 0000000000..17f55b80f3 --- /dev/null +++ b/.github/workflows/deploy_promotion.yml @@ -0,0 +1,66 @@ +name: Deploy promotion banner + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: deploy-promotion-banner + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: prod + steps: + - uses: actions/checkout@v4 + - name: Validate campaign + run: | + node --check theme/discount.js + node --test scripts/test_discount.js + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + - name: Update promotion on every deployed language + shell: bash + env: + BUCKET: hacktricks-cloud + DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} + run: | + set -euo pipefail + # Discover only existing language roots; leave book content untouched. + aws s3api list-objects-v2 --bucket "$BUCKET" --delimiter / \ + --query 'CommonPrefixes[].Prefix' --output json > /tmp/prefixes.json + python3 - <<'PYCODE' + import json, re + prefixes = [p for p in json.load(open('/tmp/prefixes.json')) if re.fullmatch(r'[a-z]{2}/', p)] + if 'en/' not in prefixes: + raise SystemExit('Expected the deployed English book') + with open('/tmp/languages.txt', 'w') as output: + output.write('\n'.join(prefixes) + '\n') + PYCODE + while IFS= read -r prefix; do + aws s3 cp src/images/hacktricks-black-friday-2026-v1.webp \ + "s3://${BUCKET}/${prefix}images/hacktricks-black-friday-2026-v1.webp" \ + --content-type image/webp --cache-control 'public,max-age=31536000,immutable' --only-show-errors + # mdBook versions have emitted both paths. Cover both existing page layouts. + for script in theme/discount.js discount.js; do + aws s3 cp theme/discount.js "s3://${BUCKET}/${prefix}${script}" \ + --content-type application/javascript --cache-control 'public,max-age=300' --only-show-errors + done + for stylesheet in theme/discount.css discount.css; do + aws s3 cp theme/discount.css "s3://${BUCKET}/${prefix}${stylesheet}" \ + --content-type text/css --cache-control 'public,max-age=300' --only-show-errors + done + done < /tmp/languages.txt + invalidation_id=$(aws cloudfront create-invalidation \ + --distribution-id "$DISTRIBUTION_ID" \ + --paths '/*/theme/discount.js' '/*/discount.js' '/*/theme/discount.css' '/*/discount.css' \ + --query Invalidation.Id --output text) + aws cloudfront wait invalidation-completed \ + --distribution-id "$DISTRIBUTION_ID" --id "$invalidation_id" diff --git a/.github/workflows/translate_all.yml b/.github/workflows/translate_all.yml index dbcc892c9f..e30d542951 100644 --- a/.github/workflows/translate_all.yml +++ b/.github/workflows/translate_all.yml @@ -110,6 +110,7 @@ jobs: mkdir -p /tmp/immutable-images cp \ src/images/hacktricks-summer-discount-2026-v1.webp \ + src/images/hacktricks-black-friday-2026-v1.webp \ src/images/arte-badge-v1.webp \ src/images/grte-badge-v1.webp \ src/images/azrte-badge-v1.webp \ @@ -120,6 +121,7 @@ jobs: src/images/arte-sponsor-v1.webp \ /tmp/immutable-images/ cp theme/discount.js /tmp/discount.js + cp theme/discount.css /tmp/discount.css - name: Run get_and_save_refs.py run: | @@ -213,6 +215,10 @@ jobs: MDBOOK_BOOK__LANGUAGE=$BRANCH mdbook build || (echo "Error logs" && cat hacktricks-preprocessor-error.log && echo "" && echo "" && echo "Debug logs" && (cat hacktricks-preprocessor.log | tail -n 20) && exit 1) cp /tmp/immutable-images/* ./book/images/ cp /tmp/discount.js ./book/discount.js + mkdir -p ./book/theme + cp /tmp/discount.js ./book/theme/discount.js + cp /tmp/discount.css ./book/discount.css + cp /tmp/discount.css ./book/theme/discount.css cp /tmp/ht_searcher.js ./book/theme/ht_searcher.js - name: Push search index to hacktricks-searchindex repo diff --git a/scripts/test_discount.js b/scripts/test_discount.js new file mode 100644 index 0000000000..9c917c0e18 --- /dev/null +++ b/scripts/test_discount.js @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { test } = require('node:test') +const vm = require('node:vm') + +const source = readFileSync(require('node:path').join(__dirname, '../theme/discount.js'), 'utf8') +const KEY = 'htDiscountDismissedUntil:black-friday-2026' +const TEN_DAYS = 10 * 24 * 60 * 60 * 1000 + +function load(date, { storage = new Map(), blocked = false, search = '', loading = false } = {}) { + const now = Date.parse(date) + const nodes = [] + const listeners = new Map() + function element() { + const node = { + style: { setProperty() {} }, + children: [], attributes: {}, handlers: {}, isConnected: false, + classList: { contains: () => false, add() {}, remove() {} }, + setAttribute(name, value) { this.attributes[name] = value }, + append(...children) { children.forEach(child => this.appendChild(child)) }, + appendChild(child) { this.children.push(child); child.isConnected = true }, + addEventListener(name, fn) { this.handlers[name] = fn }, + remove() { this.isConnected = false }, + } + nodes.push(node) + return node + } + const document = { + readyState: loading ? 'loading' : 'complete', + baseURI: 'https://cloud.hacktricks.wiki/es/nested/page.html', + documentElement: element(), body: element(), createElement: element, + querySelector: () => nodes.find(n => n.className === 'ht-discount-overlay' && n.isConnected), + addEventListener: (name, fn) => listeners.set(name, fn), + removeEventListener: name => listeners.delete(name), + } + const window = { + path_to_root: '../', location: { search, href: document.baseURI }, + matchMedia: () => ({ matches: true }), setTimeout: fn => fn(), + localStorage: { + getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) ?? null }, + setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) }, + }, + } + class Clock extends Date { static now() { return now } } + vm.runInNewContext(source, { window, document, Date: Clock, URL, URLSearchParams }) + return { nodes, storage, listeners, overlay: () => document.querySelector(), find: name => nodes.find(n => n.className === name) } +} + +test('campaign appears only throughout November 2026, including UTC boundaries', () => { + for (const date of ['2026-11-01T00:00:00Z', '2026-11-30T23:59:59.999Z']) { + assert.ok(load(date).overlay(), date) + } + for (const date of ['2026-10-31T23:59:59.999Z', '2026-12-01T00:00:00Z', '2026-07-15T12:00:00Z', '2027-11-08T12:00:00Z']) { + const page = load(date) + assert.equal(page.overlay(), undefined, date) + assert.equal(page.find('ht-discount-image'), undefined, 'inactive campaigns must not download the image') + } +}) + +test('translated nested pages use their language root and the training courses link', () => { + const page = load('2026-11-08T12:00:00Z') + assert.equal(page.find('ht-discount-image').src, 'https://cloud.hacktricks.wiki/es/images/hacktricks-black-friday-2026-v1.webp') + assert.match(page.find('ht-discount-image').alt, /20% off courses, 15% off labs and exams/) + assert.equal(page.find('ht-discount-primary').href, 'https://hacktricks-training.com/courses') +}) + +test('hide persists across navigation and expires exactly after ten days', () => { + const page = load('2026-11-08T12:00:00Z') + page.find('ht-discount-delay').handlers.click() + assert.equal(page.overlay(), undefined) + assert.equal(Number(page.storage.get(KEY)), Date.parse('2026-11-08T12:00:00Z') + TEN_DAYS) + assert.equal(load('2026-11-18T11:59:59.999Z', page).overlay(), undefined) + assert.ok(load('2026-11-18T12:00:00Z', page).overlay()) +}) + +test('summer dismissal does not hide Black Friday and preview overrides its dismissal', () => { + const storage = new Map([['htDiscountDismissedUntil:summer-2026', String(Date.parse('2026-12-01T00:00:00Z'))]]) + assert.ok(load('2026-11-08T12:00:00Z', { storage }).overlay()) + storage.set(KEY, String(Date.parse('2026-11-20T12:00:00Z'))) + assert.equal(load('2026-11-08T12:00:00Z', { storage }).overlay(), undefined) + assert.ok(load('2026-11-08T12:00:00Z', { storage, search: '?discount=show' }).overlay()) + assert.equal(load('2026-12-01T00:00:00Z', { storage, search: '?discount=show' }).overlay(), undefined) +}) + +test('close, Escape and outside click close only the current page', () => { + for (const method of ['close', 'escape', 'outside']) { + const page = load('2026-11-08T12:00:00Z') + if (method === 'close') page.find('ht-discount-close').handlers.click() + if (method === 'escape') page.listeners.get('keydown')({ key: 'Escape' }) + if (method === 'outside') page.overlay().handlers.click({ target: page.overlay() }) + assert.equal(page.overlay(), undefined) + assert.equal(page.listeners.has('keydown'), false) + assert.equal(page.storage.size, 0) + assert.ok(load('2026-11-08T12:00:01Z', page).overlay()) + } +}) + +test('blocked storage keeps the page and close controls working', () => { + const page = load('2026-11-08T12:00:00Z', { blocked: true }) + assert.ok(page.overlay()) + assert.doesNotThrow(() => page.find('ht-discount-delay').handlers.click()) + assert.equal(page.overlay(), undefined) +}) + +test('initial page load waits for the DOM', () => { + const page = load('2026-11-08T12:00:00Z', { loading: true }) + assert.equal(page.overlay(), undefined) + page.listeners.get('DOMContentLoaded')() + assert.ok(page.overlay()) +}) diff --git a/scripts/upload_immutable_images.sh b/scripts/upload_immutable_images.sh index 84c569cda6..14e26d5348 100644 --- a/scripts/upload_immutable_images.sh +++ b/scripts/upload_immutable_images.sh @@ -13,6 +13,7 @@ cache_control="public,max-age=31536000,immutable" for image in \ hacktricks-summer-discount-2026-v1.webp \ + hacktricks-black-friday-2026-v1.webp \ arte-badge-v1.webp \ grte-badge-v1.webp \ azrte-badge-v1.webp \ diff --git a/src/images/hacktricks-black-friday-2026-v1.webp b/src/images/hacktricks-black-friday-2026-v1.webp new file mode 100644 index 0000000000..09e7557c16 Binary files /dev/null and b/src/images/hacktricks-black-friday-2026-v1.webp differ diff --git a/theme/discount.css b/theme/discount.css index 013c853857..a86ee4bffc 100644 --- a/theme/discount.css +++ b/theme/discount.css @@ -36,7 +36,8 @@ width: min(92vw, 520px); max-height: min(92vh, 760px); isolation: isolate; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; border-radius: 16px; padding: 12px; background: transparent; diff --git a/theme/discount.js b/theme/discount.js index da918ffa19..a098b593cc 100644 --- a/theme/discount.js +++ b/theme/discount.js @@ -1,10 +1,10 @@ ;(function discount() { - var CAMPAIGN_ID = "summer-" + new Date().getFullYear() + var CAMPAIGN_ID = "black-friday-2026" var DISMISS_KEY = "htDiscountDismissedUntil:" + CAMPAIGN_ID var DISMISS_MS = 10 * 24 * 60 * 60 * 1000 - var IMAGE_PATH = "images/hacktricks-summer-discount-2026-v1.webp" - var TARGET_URL = "https://hacktricks-training.com" - var TITLE = "HackTricks Training Summer Discount" + var IMAGE_PATH = "images/hacktricks-black-friday-2026-v1.webp" + var TARGET_URL = "https://hacktricks-training.com/courses" + var TITLE = "HackTricks Training Black Friday 2026 — 20% off courses, 15% off labs and exams, plus 10% off bundles" function storageGet(key) { try { @@ -45,8 +45,9 @@ } function isCampaignActive() { - // getMonth() is zero-based; 6 covers July 1 through July 31 in the visitor's local time. - return new Date().getMonth() === 6 + // Match the training campaign: all of November 2026, using UTC boundaries. + var now = Date.now() + return now >= Date.UTC(2026, 10, 1) && now < Date.UTC(2026, 11, 1) } function isDismissed() { @@ -68,7 +69,7 @@ }, 220) } - function dismissFor15Days(overlay, onKeydown) { + function dismissFor10Days(overlay, onKeydown) { storageSet(DISMISS_KEY, String(Date.now() + DISMISS_MS)) close(overlay, onKeydown) } @@ -136,7 +137,7 @@ var kicker = document.createElement("span") kicker.className = "ht-discount-kicker" - kicker.textContent = "Summer offer" + kicker.textContent = "Black Friday offer" var closeButton = document.createElement("button") closeButton.className = "ht-discount-close" @@ -191,7 +192,7 @@ }) delayButton.addEventListener("click", function() { - dismissFor15Days(overlay, onKeydown) + dismissFor10Days(overlay, onKeydown) }) overlay.addEventListener("click", function(event) {