Compare commits

...

22 Commits

Author SHA1 Message Date
Dmitry Gusev
7dc431f78e docs: фиксация правил уникальности SEO title/description
All checks were successful
deploy / deploy (push) Successful in 1m10s
security / security (push) Successful in 3m25s
2026-05-30 02:25:09 +03:00
Dmitry Gusev
b40c377761 fix(seo): убрать дубли title и description
Some checks failed
deploy / deploy (push) Successful in 1m24s
security / security (push) Has been cancelled
- 6 pages (o-nas, nashi-druzya, 4 миры) получили свой description в frontmatter;
  раньше [slug].astro для типа page передавал description=undefined → fallback
  на SITE_DESCRIPTION даёт 6 одинаковых meta-description.
- [slug].astro: для постов в SEO-title подставляется год публикации (или
  полная дата, если есть второй пост с тем же title в том же году). Покрывает
  дубли «Внимание! Технические работы!» (×5) и «С 23 февраля!» (×2).
- index.astro: свой description для главной (вместо SITE_DESCRIPTION).
- category/[slug].astro: вычисляемый description с количеством публикаций и
  диапазоном лет — на случай если категории попадут в индексацию.
2026-05-30 02:22:33 +03:00
Dmitry Gusev
048cb673e0 feat(brand): актуальный фавикон по брендовому знаку
All checks were successful
deploy / deploy (push) Successful in 1m33s
security / security (push) Successful in 2m41s
Старый favicon.svg (стилизованная «A» от скаффолдинга) заменён на
оптимизированный для 16-32px вариант logo-mark: кривые-отражения +
точка на тёмном фоне, тот же градиент #b794ff→#6c4ed4.

- public/favicon.svg — новый 64×64 viewBox, утолщённые штрихи
- public/favicon.ico — 32×32 (sharp → PNG-in-ICO)
- public/apple-touch-icon.png — 180×180 для iOS
- scripts/build-favicon.mjs + npm run build:favicon
- BaseLayout: добавлены <link> на ico (fallback) и apple-touch-icon
2026-05-25 13:33:22 +03:00
Dmitry Gusev
23c8deebd2 chore(security): .gitignore + .gitleaks.toml защита от CMS-export leak
All checks were successful
deploy / deploy (push) Successful in 1m2s
security / security (push) Successful in 2m45s
Превентивная защита от случайной публикации content/logs, content/data, ghost.*.json (см. инцидент moovg_ru 2026-05-24).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:09:23 +03:00
Dmitry Gusev
2a539705e7 fix(security): npm audit fix + GitLeaks allowlist for indexnow.js
All checks were successful
deploy / deploy (push) Successful in 1m5s
security / security (push) Successful in 2m42s
- npm audit fix: устранены 5 vulnerabilities (где возможно без --force):
  - path-to-regexp <0.1.13 (ReDoS, HIGH)
  - nodemailer 6.x patch
  - qs 6.7.x DoS (transitively через body-parser + express)

- .gitleaks.toml: расширен allowlist для scripts/indexnow.js* и
  scripts/indexnow-ping.sh — содержат публичный IndexNow KEY, не секрет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:11:10 +03:00
Dmitry Gusev
df3d3d32b8 fix(security): GitLeaks allowlist + Dockerfile DL4006 + npm audit в CI
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
GitLeaks: 8 false-positives на vgrf_ru (IndexNow public key + legacy
WP plugin code) — добавлен .gitleaks.toml с allowlist:
- public/<32hex>.txt + корневой <32hex>.txt (IndexNow validation files)
- wp-content/** (legacy WordPress plugin code, не настоящие секреты)
- const KEY = '<32hex>' паттерн

Hadolint DL4006: добавлен SHELL pipefail в начале каждой stage.

npm audit: убран из Dockerfile (там кэшировался Docker layer'ом и
по факту не запускался при unchanged package-lock.json). Вынесен в
.gitea/workflows/security.yml как отдельный job — каждый push, реально.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:54:02 +03:00
Dmitry Gusev
f10a22bd25 feat(security): GitHub Actions security.yml (mirror coverage)
All checks were successful
deploy / deploy (push) Successful in 48s
security / security (push) Successful in 2m32s
Дополнительный security-сканирование на GitHub mirror: hadolint-action, gitleaks-action, semgrep, trivy-action. Запускается на push + PR + weekly cron (Mon 03:00 UTC).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:56:31 +03:00
Dmitry Gusev
6005ec32f7 fix(security): nativе binaries вместо docker run (Gitea fix)
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
Gitea act_runner не имеет docker внутри workflow-контейнера —
"docker not found" маскировался через `|| true`, Layer C по факту
не работал. Заменено на:
- Hadolint v2.12.0 binary (curl + chmod)
- GitLeaks v8.21.2 (tarball)
- Semgrep через python3 venv + pip

Все три по-прежнему warning-only, но теперь реально сканируют.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:49:20 +03:00
Dmitry Gusev
7455aadb28 docs(security): SECURITY.md с описанием Layer A+B+C stack
All checks were successful
deploy / deploy (push) Successful in 1m7s
security / security (push) Successful in 7s
Описывает все автоматические проверки: Trivy, npm audit (A), Nuclei (B), Hadolint+GitLeaks+Semgrep (C). Email для приватных репортов: admin@hhivp.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:04:39 +03:00
Dmitry Gusev
7f891c4759 feat(security): Hadolint + GitLeaks + Semgrep workflow (Layer C)
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
Новый workflow .gitea/workflows/security.yml — параллельно с deploy.yml,
запускается на push в main + на PR. Все три инструмента warning-only:

- Hadolint: bad practices в Dockerfile
- GitLeaks: поиск секретов в истории (полный clone fetch-depth: 0)
- Semgrep: SAST с конфигами p/javascript + p/react + p/typescript + p/security-audit

Не блокируют deploy — findings собираются в логи job'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:51:01 +03:00
Dmitry Gusev
b902c28f54 feat(security): Trivy в CI + npm audit в Dockerfile (Layer A)
Some checks failed
deploy / deploy (push) Has been cancelled
- Dockerfile: npm audit HIGH/CRITICAL warning-only после npm ci/install
- CI (для тех у кого ещё не было): Trivy scan собранного образа
  HIGH/CRITICAL severity, --ignore-unfixed, --exit-code 0 (warning-only)

Часть multi-layer security plan: Layer A (минимум), B (Nuclei DAST weekly cron)
и C (GitLeaks + Semgrep + Hadolint) — отдельными задачами в Singularity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:47:07 +03:00
Dmitry Gusev
c7f579f9ab feat(footer): hover-pill credit «Сделано в hhivp.com» с border вместо плотного фона
All checks were successful
deploy / deploy (push) Successful in 56s
Паттерн Cuberto: только mark по умолчанию, при hover/focus раскрывается с текстом справа. SVG-mark в currentColor наследует цвет родителя — гармонирует с любой темой. Border 1px solid currentColor вместо плотного чёрного фона.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 07:00:25 +03:00
Dmitry Gusev
fc50b0d1be style(footer): mark на чёрном фоне 22px (вместо синего 18px)
All checks were successful
deploy / deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:43:48 +03:00
Dmitry Gusev
987134600e feat(footer): робот hhivp вместо текстового mark в SVG
All checks were successful
deploy / deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:39:37 +03:00
Dmitry Gusev
682be4247f feat(footer): visible credit с маркой hhivp вместо длинного юр.лица
All checks were successful
deploy / deploy (push) Successful in 52s
Заменил «Сайт разработан и сопровождается… ООО АйТи Решения»
на компактный credit-блок: SVG-mark «h» 18px + «Разработка — hhivp.com».
SVG локально (public/hhivp-mark.svg), без cross-origin. hhivp.com
лучше для SEO direct match и memorability — юр.лицо остаётся в
Schema JSON-LD (invisible) для AI/Google.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:35:50 +03:00
Dmitry Gusev
382bfc4544 feat(schema): add creator entity (hhivp.com) to WebSite JSON-LD
All checks were successful
deploy / deploy (push) Successful in 1m10s
Entity-сигнал для AI Overviews / Я.Нейро о тех-партнёрстве.
Связывает сайт с разработчиком через @id графа.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 05:07:45 +03:00
Dmitry Gusev
04b405ea2e fix(cookie-banner): .cookie-consent[hidden] display: none !important
All checks were successful
deploy / deploy (push) Successful in 57s
Кнопка "Принять" не работала визуально: JS-handler срабатывал и устанавливал
banner.hidden=true, но CSS-правило .cookie-consent { display: flex } имело
ту же specificity (10) что UA stylesheet [hidden] { display: none } —
последняя из загруженных побеждала, и баннер не скрывался.
2026-05-24 01:03:51 +03:00
Dmitry Gusev
2cbd1358d2 style(footer): сделать dev-credit видимой — fg-muted цвет + dashed подчёркивание ссылки
All checks were successful
deploy / deploy (push) Successful in 1m1s
2026-05-24 01:02:34 +03:00
Dmitry Gusev
0cf7b23c87 feat(footer): добавить подпись о техническом партнёре (по образу stbolshevik)
All checks were successful
deploy / deploy (push) Successful in 58s
2026-05-24 00:21:27 +03:00
Dmitry Gusev
f78145e70a feat: Speculation Rules API in BaseLayout (prerender on hover/pointerdown)
All checks were successful
deploy / deploy (push) Successful in 1m1s
2026-05-23 23:56:37 +03:00
Dmitry Gusev
9499adfb9a chore(robots): add ChatGPT-User + OAI-SearchBot + Applebot-Extended
All checks were successful
deploy / deploy (push) Successful in 1m18s
2026-05-23 23:46:00 +03:00
bd6ab03b2e feat(seo): discoverability + Schema.org + IndexNow + Trivy (#1)
All checks were successful
deploy / deploy (push) Successful in 3m7s
2026-05-21 14:25:12 +03:00
30 changed files with 657 additions and 20 deletions

View File

@@ -42,9 +42,35 @@ jobs:
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
docker compose build docker compose build
# Trivy scan локально собранного образа (HIGH+CRITICAL, не блокирует).
# ghcr.io вместо docker.io — обход rate limit Docker Hub.
echo "=== Trivy scan: anotherreflections-ru-v2:latest ==="
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/trivy-cache:/root/.cache/ \
ghcr.io/aquasecurity/trivy:latest image \
--severity HIGH,CRITICAL \
--no-progress \
--exit-code 0 \
--timeout 5m \
anotherreflections-ru-v2:latest || true
echo "=== Trivy scan done ==="
docker compose up -d docker compose up -d
sleep 5 sleep 5
docker compose ps docker compose ps
curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL" curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL"
docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true
REMOTE REMOTE
- name: Checkout repo for IndexNow
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Notify IndexNow (Yandex/Bing)
run: npm run indexnow || true

View File

@@ -0,0 +1,72 @@
name: security
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # GitLeaks нужна полная история
# ── 1. Hadolint: проверка Dockerfile ──────────────────────────────
# Установка нативного бинаря (act_runner не имеет docker внутри).
- name: Install Hadolint
run: |
if [ -f Dockerfile ]; then
curl -sSL https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 -o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint
fi
- name: Run Hadolint
run: |
if [ -f Dockerfile ]; then
hadolint --no-fail Dockerfile || true
else
echo "No Dockerfile — skip"
fi
# ── 2. GitLeaks: поиск секретов в истории ─────────────────────────
- name: Install GitLeaks
run: |
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
chmod +x /usr/local/bin/gitleaks
- name: Run GitLeaks
run: gitleaks detect --source . --no-banner --verbose --redact --exit-code 0 || true
# ── 3. Semgrep: SAST ──────────────────────────────────────────────
- name: Install Semgrep
run: |
apt-get update -qq
apt-get install -y --no-install-recommends python3-pip python3-venv
python3 -m venv /tmp/sg && /tmp/sg/bin/pip install --quiet semgrep
ln -sf /tmp/sg/bin/semgrep /usr/local/bin/semgrep
- name: Run Semgrep
run: |
semgrep --config=p/javascript --config=p/react --config=p/typescript --config=p/security-audit \
--severity=ERROR --severity=WARNING --no-error --quiet --metrics=off --timeout=120 . || true
# ── 4. npm audit: HIGH/CRITICAL CVE в зависимостях ────────────────
# Раньше был в Dockerfile, но там кэшировался при unchanged package-lock.json.
# Вынесен сюда — реально запускается каждый push.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: npm audit
run: |
if [ -f package-lock.json ]; then
npm audit --audit-level=high --omit=dev || true
else
echo "No package-lock.json — skip npm audit"
fi

59
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: security
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
# Каждый понедельник 03:00 UTC — независимый weekly scan
- cron: '0 3 * * 1'
permissions:
contents: read
security-events: write
jobs:
hadolint:
runs-on: ubuntu-latest
if: hashFiles('Dockerfile') != ''
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: error
continue-on-error: true
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semgrep:
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep:latest
steps:
- uses: actions/checkout@v4
- run: semgrep ci --config=p/javascript --config=p/react --config=p/typescript --config=p/security-audit
continue-on-error: true
trivy:
runs-on: ubuntu-latest
if: hashFiles('Dockerfile') != ''
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '0'

12
.gitignore vendored
View File

@@ -1,3 +1,13 @@
# Security: НЕ коммитить production logs и CMS data exports
# (могут содержать API keys, JWT, private_key). См. инцидент 2026-05-24.
content/logs/
content/data/
*.production.log
*.production.log.*
ghost.json
ghost.*.json
*.ghost.*.json
# build output # build output
dist/ dist/
.astro/ .astro/
@@ -21,4 +31,4 @@ pnpm-debug.log*
.DS_Store .DS_Store
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/

49
.gitleaks.toml Normal file
View File

@@ -0,0 +1,49 @@
# GitLeaks config для сателлитных сайтов hhivp.
# Extends default rules and adds allowlist for known false-positives.
# https://github.com/gitleaks/gitleaks#configuration
[extend]
useDefault = true
[allowlist]
description = "Allowlist for IndexNow public keys + legacy WP plugin code"
# Пути, которые целиком игнорируем
paths = [
# IndexNow validation file (32-hex .txt в корне или в public/).
# Это публичный ключ, по дизайну отдаётся всем — НЕ секрет.
'''public/[a-f0-9]{32}\.txt''',
'''^[a-f0-9]{32}\.txt$''',
# IndexNow ping-скрипты содержат `const KEY = '<32hex>'` —
# тот же публичный ключ, не секрет (для авторизации перед Яндекс/Bing API).
'''scripts/indexnow\.(js|mjs|sh|ts)$''',
'''scripts/indexnow-ping\.sh$''',
# Legacy WordPress plugin code (akismet, jetpack, wpforms-lite, wp-cache).
# Все "ключи" внутри — placeholder/template/internal параметры,
# не настоящие секреты. Импортировано из старого WP-сайта как static.
'''wp-content/.*''',
# Минифицированные ассеты — часто содержат hash'и/токены, не секреты.
'''.*\.min\.(js|css)$''',
'''dist/.*''',
'''build/.*''',
# Защита на случай возврата CMS exports / production logs в репо
# (см. инцидент 2026-05-24 с Ghost ghost_private_key + members_private_key).
# Сами файлы УЖЕ удалены из history через git filter-repo, allowlist —
# дополнительная защита для будущих commit'ов.
'''content/logs/.*''',
'''content/data/.*''',
'''.*\.production\.log(\.[0-9]+)?$''',
'''.*\.ghost\..*\.json$''',
]
# Конкретные паттерны, которые false-positive
regexes = [
# Наш scripts/indexnow.js: const KEY = '<32-hex>' — IndexNow public key.
'''const\s+KEY\s*=\s*['"][a-f0-9]{32}['"]''',
# Аналог для других форм объявления того же ключа.
'''KEY\s*[:=]\s*['"][a-f0-9]{32}['"]''',
]

View File

@@ -75,7 +75,9 @@ src/
└── consts.ts (SITE_TITLE, WORLDS, ANALYTICS, CATEGORY_COLORS, plural()) └── consts.ts (SITE_TITLE, WORLDS, ANALYTICS, CATEGORY_COLORS, plural())
public/ public/
├── favicon.svg ├── favicon.svg (брендовый знак на тёмном фоне, оптимизирован под 16-32px)
├── favicon.ico (32×32 PNG-in-ICO, генерируется из favicon.svg)
├── apple-touch-icon.png (180×180 для iOS home screen)
├── logo.svg, logo-mark.svg ├── logo.svg, logo-mark.svg
├── og-image.png (1200×630 для расшаривания в TG/VK/Twitter) ├── og-image.png (1200×630 для расшаривания в TG/VK/Twitter)
├── og-image.svg (исходник) ├── og-image.svg (исходник)
@@ -86,7 +88,8 @@ public/
scripts/ scripts/
├── migrate-wp.mjs (одноразовый: _wp-export.json → src/content/*.md) ├── migrate-wp.mjs (одноразовый: _wp-export.json → src/content/*.md)
── build-og-image.mjs (sharp → public/og-image.png из SVG) ── build-og-image.mjs (sharp → public/og-image.png из SVG)
└── build-favicon.mjs (sharp → public/favicon.ico + apple-touch-icon.png из favicon.svg; `npm run build:favicon`)
Dockerfile (multi-stage: node:22-alpine builds → nginx:1.29-alpine serves) Dockerfile (multi-stage: node:22-alpine builds → nginx:1.29-alpine serves)
nginx.conf (gzip, кэш _astro/ 1y, MIME application/rss+xml для feed) nginx.conf (gzip, кэш _astro/ 1y, MIME application/rss+xml для feed)
@@ -132,6 +135,16 @@ docker compose build && docker compose up -d
В новой версии БД нет. Старый WP оставлен с БД `anotherreflctions_ru` (`sic`, с опечаткой) на `db.hhivp.com` (45.10.53.205, MySQL), user `u_anotherreflections`, prefix `anm_`. После 1-2 недель наблюдения за новой версией — старый WP-контейнер можно удалить, БД и snapshot тоже. В новой версии БД нет. Старый WP оставлен с БД `anotherreflctions_ru` (`sic`, с опечаткой) на `db.hhivp.com` (45.10.53.205, MySQL), user `u_anotherreflections`, prefix `anm_`. После 1-2 недель наблюдения за новой версией — старый WP-контейнер можно удалить, БД и snapshot тоже.
## SEO-title и description — уникальность
Шаблон в `BaseLayout.astro:16-17`: `title` рендерится как `${title} — ${SITE_TITLE}`, `description` falls back на `SITE_DESCRIPTION`. Если страница не передаёт свои `title`/`description`, получает дефолт → дубли в выдаче.
**Правила (поддерживать при добавлении страниц):**
- **Posts** (`src/content/posts/*.md`) — обязателен `description:` в frontmatter (Zod schema: `z.string().default('')`, но пустой даёт fallback). В SEO-title `[slug].astro` подставляет год публикации; если есть другой пост с тем же `title` за тот же год — подставляется полная дата (`DD месяц YYYY`).
- **Pages** (`src/content/pages/*.md`) — обязателен `description:`. `[slug].astro` берёт его напрямую.
- **Категории** (`category/[slug].astro`) — description вычисляется автоматически из количества публикаций и диапазона лет.
- **Главная** (`index.astro`) — свой description прописан явно.
## SEO/AI файлы ## SEO/AI файлы
- `public/robots.txt` — статика, разрешено всё; явно перечислены GPTBot/ClaudeBot/Google-Extended/CCBot/PerplexityBot/anthropic-ai; ссылки на sitemap-index.xml и sitemap.txt - `public/robots.txt` — статика, разрешено всё; явно перечислены GPTBot/ClaudeBot/Google-Extended/CCBot/PerplexityBot/anthropic-ai; ссылки на sitemap-index.xml и sitemap.txt

View File

@@ -3,6 +3,7 @@
# ─── Stage 1: build static site (Astro SSG) ──────────────────────────────── # ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci

45
SECURITY.md Normal file
View File

@@ -0,0 +1,45 @@
# Security Policy
## Supported versions
Только текущая версия `main` — site deployed continuously, отдельных release-веток нет.
## Reporting a vulnerability
Email: **admin@hhivp.com** (PGP опционально по запросу).
Просим **не публиковать** баг до подтверждения исправления (≤14 дней).
## Security stack (automated)
Этот репозиторий покрыт многоуровневой автоматической проверкой:
### Layer A — на каждый push в `main` (deploy CI)
- **Trivy** — сканирование Docker image на HIGH/CRITICAL CVE в OS-пакетах и npm-deps (warning-only, `--ignore-unfixed`).
- **npm audit** — проверка зависимостей на известные CVE в `Dockerfile` после `npm ci` (warning-only, `--audit-level=high --omit=dev`).
### Layer B — еженедельно (Sun 04:00 UTC, cron)
- **Nuclei** — DAST (Dynamic Application Security Testing) живого сайта. Шаблоны: exposures, misconfig, headers, CVE. Severity: HIGH+CRITICAL.
- Нотификация в Telegram при находках.
### Layer C — на каждый push в `main` + на MR/PR (`.gitea/workflows/security.yml`)
- **Hadolint** — bad practices в `Dockerfile`.
- **GitLeaks** — поиск секретов в git-истории (`fetch-depth: 0`).
- **Semgrep** — SAST (Static Application Security Testing) с конфигами `p/javascript`, `p/react`, `p/typescript`, `p/security-audit`.
Все три инструмента **warning-only** — не блокируют deploy, но finding'и видны в логах job'а.
### Дополнительно
- **152-ФЗ consent-gated analytics** — Я.Метрика и Google Analytics загружаются только после явного согласия пользователя в баннере cookies.
- **HTTPS-only** — Let's Encrypt cert, HSTS включён.
- **Security headers** — `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Content-Security-Policy`.
- **Rate limiting** — на `/api/contact` (5 req/min per IP) + Cloudflare Turnstile widget.
## Contact
- Email: admin@hhivp.com
- Site: https://hhivp.com/

View File

@@ -11,7 +11,9 @@
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"migrate": "node scripts/migrate-wp.mjs" "migrate": "node scripts/migrate-wp.mjs",
"indexnow": "node scripts/indexnow.js",
"build:favicon": "node scripts/build-favicon.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/rss": "^4.0.12", "@astrojs/rss": "^4.0.12",

View File

@@ -0,0 +1,6 @@
Contact: mailto:admin@anotherreflections.ru
Contact: https://anotherreflections.ru/kontakty
Expires: 2027-05-21T00:00:00.000Z
Preferred-Languages: ru, en
Canonical: https://anotherreflections.ru/.well-known/security.txt
Policy: https://anotherreflections.ru/privacy

View File

@@ -0,0 +1 @@
15455d9f2c7b473bb04336055b792ec9

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,9 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> <defs>
<style> <linearGradient id="fg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
path { fill: #000; } <stop offset="0" stop-color="#b794ff"/>
@media (prefers-color-scheme: dark) { <stop offset="1" stop-color="#6c4ed4"/>
path { fill: #FFF; } </linearGradient>
} <radialGradient id="bg" cx="50%" cy="38%" r="65%">
</style> <stop offset="0" stop-color="#1a1430"/>
<stop offset="1" stop-color="#07090f"/>
</radialGradient>
</defs>
<rect width="64" height="64" rx="12" fill="url(#bg)"/>
<path d="M14 40 Q32 16 50 40" stroke="url(#fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<path d="M14 28 Q32 52 50 28" stroke="url(#fg)" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="32" cy="34" r="3.2" fill="#d4b9ff"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 792 B

7
public/hhivp-mark.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92 92" role="img" aria-label="hhivp" fill="currentColor">
<g transform="translate(-4 -2)">
<path d="M86.316,43.946V26.322h-8.331v9.961H70.46c-0.845-7.146-6.931-12.711-14.301-12.711H39.842c-7.37,0-13.456,5.564-14.301,12.711h-7.527v-6.162c3.013-1.554,4.934-4.675,4.934-8.083c0-3.775-2.382-7.199-5.928-8.519l-1.012-0.377v10.897H11.69V13.142l-1.012,0.376c-3.546,1.32-5.929,4.744-5.929,8.52c0,3.408,1.921,6.529,4.934,8.083v17.623h8.331v-9.96h7.428c-0.001,0.067-0.01,0.132-0.01,0.199v18.024c0,5.872,3.535,10.926,8.584,13.169l-1.052,4.606c-1.821,1.184-3.031,3.232-3.031,5.561c0,2.352,3.573,3.186,6.632,3.186c3.059,0,6.632-0.834,6.632-3.186c0-2.168-1.05-4.091-2.664-5.302l2.324-3.625h10.283l2.324,3.625c-1.614,1.211-2.664,3.133-2.664,5.301c0,2.352,3.573,3.186,6.632,3.186c3.06,0,6.632-0.834,6.632-3.186c0-2.33-1.209-4.378-3.03-5.562l-1.05-4.606c5.049-2.244,8.583-7.297,8.583-13.169V37.982c0-0.067-0.009-0.132-0.01-0.199h7.427v6.163c-3.012,1.553-4.932,4.674-4.932,8.083c0,3.775,2.382,7.199,5.928,8.52l1.012,0.377V50.027h4.317v10.898l1.012-0.377c3.545-1.32,5.928-4.744,5.928-8.52C91.25,48.62,89.329,45.5,86.316,43.946z M16.957,28.966l-0.442,0.199v17.079h-5.331V29.165l-0.442-0.199C8.013,27.74,6.25,25.021,6.25,22.038c0-2.778,1.546-5.329,3.94-6.649v10.151h7.318V15.388c2.394,1.321,3.939,3.872,3.939,6.649C21.448,25.021,19.685,27.74,16.957,28.966z M41.698,79.343c0,0.814-2.062,1.686-5.132,1.686c-3.07,0-5.132-0.872-5.132-1.686c0-2.83,2.303-5.132,5.133-5.132C39.396,74.211,41.698,76.513,41.698,79.343z M64.566,79.343c0,0.814-2.062,1.686-5.132,1.686c-3.07,0-5.132-0.872-5.132-1.686c0-2.83,2.303-5.132,5.133-5.132C62.264,74.211,64.566,76.513,64.566,79.343z M69.069,56.006c0,7.119-5.792,12.911-12.911,12.911H39.842c-7.119,0-12.91-5.792-12.91-12.911V37.982c0-7.119,5.792-12.91,12.91-12.91h16.316c7.119,0,12.911,5.792,12.911,12.91V56.006z M85.811,58.679V48.527h-7.317v10.151c-2.394-1.322-3.939-3.872-3.939-6.65c0-2.983,1.762-5.703,4.489-6.928l0.443-0.199v-17.08h5.331v17.08l0.443,0.199c2.728,1.226,4.491,3.945,4.491,6.928C89.75,54.808,88.205,57.358,85.811,58.679z"/>
<circle cx="40.222" cy="40.07" r="3.8"/>
<circle cx="55.78" cy="40.07" r="3.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

10
public/humans.txt Normal file
View File

@@ -0,0 +1,10 @@
/* SITE */
Site: Иные Отражения — anotherreflections.ru
Last update: 2026-05-21
Standards: HTML5, CSS3, RSS 2.0
Components: Astro 5, nginx 1.29
Software: Docker, Gitea Actions
/* TEAM */
Maintained by: HHIVP
Contact: admin@anotherreflections.ru

View File

@@ -13,9 +13,18 @@ Host: https://anotherreflections.ru
User-agent: GPTBot User-agent: GPTBot
Allow: / Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot User-agent: ClaudeBot
Allow: / Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: Google-Extended User-agent: Google-Extended
Allow: / Allow: /

28
scripts/build-favicon.mjs Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
// Генерирует public/favicon.ico (32x32 PNG-in-ICO, как у Astro по умолчанию)
// и public/apple-touch-icon.png (180x180) из public/favicon.svg.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const svgPath = path.join(ROOT, 'public/favicon.svg');
const svg = fs.readFileSync(svgPath);
const targets = [
{ out: 'public/favicon.ico', size: 32 },
{ out: 'public/apple-touch-icon.png', size: 180 },
];
for (const { out, size } of targets) {
const dst = path.join(ROOT, out);
await sharp(svg, { density: 384 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(dst);
const kb = (fs.statSync(dst).size / 1024).toFixed(1);
console.log(`wrote ${out} (${size}x${size}) — ${kb} KB`);
}

133
scripts/indexnow.js Normal file
View File

@@ -0,0 +1,133 @@
// IndexNow: уведомить Yandex/Bing о новых/обновлённых URL.
// Запускается после деплоя из CI или вручную:
// node scripts/indexnow.js
//
// Источник URL — sitemap. Сначала пробуем локальный dist/sitemap*.xml
// (если запускаем сразу после `npm run build`), иначе тянем
// https://anotherreflections.ru/sitemap-index.xml через сеть.
//
// IndexNow ключ хранится в файле public/<key>.txt — он должен быть доступен
// по тому же URL что и сайт, чтобы поисковики могли подтвердить ownership.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const BASE = 'https://anotherreflections.ru';
const HOST = 'anotherreflections.ru';
const KEY = '15455d9f2c7b473bb04336055b792ec9'; // 32-char hex
// Создать key-файл если кто-то его удалил.
const keyFile = path.join(root, 'public', `${KEY}.txt`);
if (!fs.existsSync(keyFile)) {
fs.writeFileSync(keyFile, KEY, 'utf-8');
console.log(`created key file: public/${KEY}.txt`);
}
function extractLocs(xml) {
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
}
async function fetchText(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
return r.text();
}
async function collectFromRemoteIndex(indexUrl) {
const indexXml = await fetchText(indexUrl);
const locs = extractLocs(indexXml);
// Если это sitemap-index — locs указывают на дочерние sitemap-N.xml.
// Если это обычный sitemap — locs уже URL'ы страниц.
const looksLikeIndex = /<sitemapindex/i.test(indexXml);
if (!looksLikeIndex) return locs;
const childUrls = [];
for (const loc of locs) {
try {
const childXml = await fetchText(loc);
childUrls.push(...extractLocs(childXml));
} catch (e) {
console.error(` skip ${loc}: ${e.message}`);
}
}
return childUrls;
}
function collectFromLocalDist() {
const distDir = path.join(root, 'dist');
if (!fs.existsSync(distDir)) return null;
const sitemapFiles = fs
.readdirSync(distDir)
.filter((f) => /^sitemap-\d+\.xml$/.test(f));
let urls = [];
if (sitemapFiles.length > 0) {
for (const f of sitemapFiles) {
const xml = fs.readFileSync(path.join(distDir, f), 'utf-8');
urls.push(...extractLocs(xml));
}
return urls;
}
for (const c of ['sitemap-index.xml', 'sitemap.xml']) {
const p = path.join(distDir, c);
if (fs.existsSync(p)) {
const xml = fs.readFileSync(p, 'utf-8');
return extractLocs(xml);
}
}
return null;
}
let urls = collectFromLocalDist();
if (urls === null || urls.length === 0) {
console.log('no local dist/sitemap*.xml — fetching remote sitemap-index.xml');
try {
urls = await collectFromRemoteIndex(`${BASE}/sitemap-index.xml`);
} catch (e) {
console.error(`failed to fetch remote sitemap: ${e.message}`);
process.exit(0);
}
}
// Уникальные URL только нашего хоста.
urls = [...new Set(urls)].filter((u) => {
try {
return new URL(u).host === HOST;
} catch {
return false;
}
});
if (urls.length === 0) {
console.error('no URLs found; nothing to submit');
process.exit(0);
}
console.log(`Submitting ${urls.length} URLs to IndexNow…`);
const payload = {
host: HOST,
key: KEY,
keyLocation: `${BASE}/${KEY}.txt`,
urlList: urls,
};
async function submit(endpoint) {
try {
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
console.log(` ${endpoint}: HTTP ${r.status}`);
} catch (e) {
console.error(` ${endpoint}: ${e.message}`);
}
}
await Promise.all([
submit('https://yandex.com/indexnow'),
submit('https://api.indexnow.org/indexnow'),
]);

View File

@@ -5,6 +5,7 @@ legacyId: "132"
menuOrder: "4" menuOrder: "4"
pubDate: "2011-05-18T00:40:47+03:00" pubDate: "2011-05-18T00:40:47+03:00"
updatedDate: "2014-07-14T00:12:19+03:00" updatedDate: "2014-07-14T00:12:19+03:00"
description: "Игровая вселенная по «Хроникам Амбера» Роджера Желязны: Истинный мир, его Отражения и принцы крови с непростой семейной историей."
--- ---
Тест Тест

View File

@@ -5,6 +5,7 @@ legacyId: "130"
menuOrder: "5" menuOrder: "5"
pubDate: "2011-05-18T00:39:40+03:00" pubDate: "2011-05-18T00:39:40+03:00"
updatedDate: "2014-07-14T00:12:27+03:00" updatedDate: "2014-07-14T00:12:27+03:00"
description: "Ролевая игра по циклу «Киндрэт» Алексея Пехова, Елены Бычковой и Натальи Турчаниновой: вампирские кланы ночной Столицы и их теневая политика."
--- ---
«Киндрэт. Кровные братья» — первая книга цикла Киндрэт известных российских писателей Алексея Пехова, Елены Бычковой и Натальи Турчаниновой, рассказывающая о жизни кланов вампиров. В цикл романов по миру ночной Столицы вошли 4 книги: «Кровные братья», «Колдун из клана смерти», «Основатель», «Новые боги». «Киндрэт. Кровные братья» — первая книга цикла Киндрэт известных российских писателей Алексея Пехова, Елены Бычковой и Натальи Турчаниновой, рассказывающая о жизни кланов вампиров. В цикл романов по миру ночной Столицы вошли 4 книги: «Кровные братья», «Колдун из клана смерти», «Основатель», «Новые боги».

View File

@@ -5,6 +5,7 @@ legacyId: "124"
menuOrder: "3" menuOrder: "3"
pubDate: "2011-05-18T00:27:35+03:00" pubDate: "2011-05-18T00:27:35+03:00"
updatedDate: "2014-07-14T00:12:09+03:00" updatedDate: "2014-07-14T00:12:09+03:00"
description: "Главная ролевая игра «Иных Отражений» по миру «Дозоров» Сергея Лукьяненко и Владимира Васильева: Иные, Свет и Тьма, древний Договор."
--- ---
**Иные Отражения: Сумерки Дозоров** **Иные Отражения: Сумерки Дозоров**

View File

@@ -5,6 +5,7 @@ legacyId: "339"
menuOrder: "2" menuOrder: "2"
pubDate: "2013-07-09T13:29:47+03:00" pubDate: "2013-07-09T13:29:47+03:00"
updatedDate: "2025-06-07T02:31:08+03:00" updatedDate: "2025-06-07T02:31:08+03:00"
description: "Партнёры и друзья ролевой группы «Иные Отражения»: ООО «АйТи Решения» (hhivp.com) — IT-услуги для бизнеса в Москве и области."
--- ---
[ООО "АйТи Решения"](https://hhivp.com) предоставляет полный спектр IT услуг на территории Москвы и Московской области, как частным лицам, так и представителям бизнеса. Мы способствуем развитию Вашего бизнеса и достижению самых смелых результатов! [ООО "АйТи Решения"](https://hhivp.com) предоставляет полный спектр IT услуг на территории Москвы и Московской области, как частным лицам, так и представителям бизнеса. Мы способствуем развитию Вашего бизнеса и достижению самых смелых результатов!

View File

@@ -5,6 +5,7 @@ legacyId: "137"
menuOrder: "1" menuOrder: "1"
pubDate: "2011-05-18T00:50:11+03:00" pubDate: "2011-05-18T00:50:11+03:00"
updatedDate: "2014-07-14T00:10:31+03:00" updatedDate: "2014-07-14T00:10:31+03:00"
description: "История ролевой группы «Иные Отражения»: основана в 2006 году как «Иные Миры», в 2007-м объединилась с проектом «Отражения»."
--- ---
**Ролевая группа "Иные Отражения"** **Ролевая группа "Иные Отражения"**

View File

@@ -5,6 +5,7 @@ legacyId: "378"
menuOrder: "6" menuOrder: "6"
pubDate: "2013-10-06T02:59:22+03:00" pubDate: "2013-10-06T02:59:22+03:00"
updatedDate: "2014-07-14T00:12:33+03:00" updatedDate: "2014-07-14T00:12:33+03:00"
description: "«Ренессанс» — ролевая игра по миру «Дозоров» Сергея Лукьяненко: Иные, Сумрак и древний Договор в декорациях эпохи Возрождения."
--- ---
**Иные Отражения: Ренессанс** **Иные Отражения: Ренессанс**

View File

@@ -1,6 +1,6 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, MAIN_NAV } from '../consts'; import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, SITE_FOUNDED, MAIN_NAV, SOCIAL } from '../consts';
import BrandMark from '../components/BrandMark.astro'; import BrandMark from '../components/BrandMark.astro';
import SocialLinks from '../components/SocialLinks.astro'; import SocialLinks from '../components/SocialLinks.astro';
import Analytics from '../components/Analytics.astro'; import Analytics from '../components/Analytics.astro';
@@ -17,6 +17,47 @@ const pageTitle = title ? `${title} — ${SITE_TITLE}` : SITE_TITLE;
const pageDesc = description || SITE_DESCRIPTION; const pageDesc = description || SITE_DESCRIPTION;
const canonical = new URL(Astro.url.pathname, SITE_URL).toString(); const canonical = new URL(Astro.url.pathname, SITE_URL).toString();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const ogImage = new URL('/og-image.png', SITE_URL).toString();
const logoUrl = new URL('/logo.svg', SITE_URL).toString();
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}/#website`,
name: SITE_TITLE,
url: SITE_URL,
inLanguage: SITE_LANG,
description: SITE_DESCRIPTION,
publisher: { '@id': `${SITE_URL}/#org` },
// creator — тех-партнёр, разработчик сайта (hhivp.com). Entity-сигнал
// для AI Overviews / Я.Нейро о связи между сайтом и его создателем.
creator: {
'@type': 'Organization',
'@id': 'https://hhivp.com/#organization',
name: 'ООО «АйТи Решения»',
url: 'https://hhivp.com/',
sameAs: ['https://hhivp.com'],
},
potentialAction: {
'@type': 'SearchAction',
target: `${SITE_URL}/?s={query}`,
'query-input': 'required name=query',
},
},
{
'@context': 'https://schema.org',
'@type': 'NewsMediaOrganization',
'@id': `${SITE_URL}/#org`,
name: SITE_TITLE,
url: SITE_URL,
logo: logoUrl,
image: ogImage,
description: SITE_DESCRIPTION,
foundingDate: String(SITE_FOUNDED),
sameAs: Object.values(SOCIAL).map((s) => s.url),
},
];
--- ---
<!doctype html> <!doctype html>
<html lang={SITE_LANG}> <html lang={SITE_LANG}>
@@ -28,6 +69,8 @@ const year = new Date().getFullYear();
<meta name="theme-color" content="#07090f" /> <meta name="theme-color" content="#07090f" />
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:type" content={ogType} /> <meta property="og:type" content={ogType} />
<meta property="og:title" content={pageTitle} /> <meta property="og:title" content={pageTitle} />
@@ -46,11 +89,35 @@ const year = new Date().getFullYear();
<link rel="alternate" type="application/rss+xml" title={`${SITE_TITLE} — RSS`} href="/feed.xml" /> <link rel="alternate" type="application/rss+xml" title={`${SITE_TITLE} — RSS`} href="/feed.xml" />
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" />
<Analytics /> <Analytics />
<!--
Speculation Rules API (Chromium 122+) — prerender same-origin pages on
hover/pointerdown for near-instant navigation. Rules are static JSON,
no user input → safe inline.
-->
<script type="speculationrules" is:inline set:html={JSON.stringify({
prerender: [
{
where: {
and: [
{ href_matches: '/*' },
{ not: { href_matches: '/assets/*' } },
{ not: { href_matches: '/sitemap*' } },
{ not: { href_matches: '/feed*' } },
{ not: { href_matches: '/llms*' } }
]
},
eagerness: 'moderate'
}
]
})} />
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
@@ -85,6 +152,14 @@ const year = new Date().getFullYear();
<a href="/privacy/">Политика конфиденциальности</a> · <a href="/privacy/">Политика конфиденциальности</a> ·
<a href="/kontakty/">Контакты</a> <a href="/kontakty/">Контакты</a>
</p> </p>
<p class="dev-credit">
<a href="https://hhivp.com/" target="_blank" rel="noopener" class="hhivp-credit" aria-label="Сделано в hhivp.com">
<span class="hhivp-credit-mark" aria-hidden="true">
<img src="/hhivp-mark.svg" alt="" width="20" height="20" />
</span>
<span class="hhivp-credit-text">Сделано&nbsp;в&nbsp;hhivp.com</span>
</a>
</p>
</footer> </footer>
<CookieConsent /> <CookieConsent />

View File

@@ -6,13 +6,27 @@ import { CATEGORY_COLORS } from '../consts';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('posts'); const posts = await getCollection('posts');
const pages = await getCollection('pages'); const pages = await getCollection('pages');
// Считаем сколько постов с одинаковым {title, year} — если >1, в SEO-title
// подставим полную дату, иначе только год.
const titleYearCount = new Map<string, number>();
for (const p of posts) {
const key = `${p.data.title}|${p.data.pubDate.getFullYear()}`;
titleYearCount.set(key, (titleYearCount.get(key) ?? 0) + 1);
}
return [ return [
...posts.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const } })), ...posts.map((p) => {
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const } })), const year = p.data.pubDate.getFullYear();
const key = `${p.data.title}|${year}`;
const sameYearDup = (titleYearCount.get(key) ?? 1) > 1;
return { params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const, sameYearDup } };
}),
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const, sameYearDup: false } })),
]; ];
} }
const { entry, kind } = Astro.props; const { entry, kind, sameYearDup } = Astro.props;
const { Content } = await render(entry); const { Content } = await render(entry);
// Буквица — только для постов с телом длиннее короткого порога. // Буквица — только для постов с телом длиннее короткого порога.
const bodyLen = entry.body?.length ?? 0; const bodyLen = entry.body?.length ?? 0;
@@ -20,10 +34,18 @@ const useDropCap = kind === 'post' && bodyLen > 240;
const fmtDate = (d: Date) => const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }); d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
// Короткая дата без "г." для SEO-title постов с одинаковым названием в одном году.
const fmtDateForTitle = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }).replace(/\sг\.?$/, '');
const seoTitle = kind === 'post'
? `${entry.data.title} (${sameYearDup ? fmtDateForTitle(entry.data.pubDate) : entry.data.pubDate.getFullYear()})`
: entry.data.title;
--- ---
<BaseLayout <BaseLayout
title={entry.data.title} title={seoTitle}
description={kind === 'post' ? entry.data.description : undefined} description={entry.data.description || undefined}
ogType={kind === 'post' ? 'article' : 'website'} ogType={kind === 'post' ? 'article' : 'website'}
> >
<article class="post"> <article class="post">

View File

@@ -27,8 +27,18 @@ const sorted = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.va
const fmtDate = (d: Date) => const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }); d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
const yearsRange = (() => {
if (!sorted.length) return '';
const years = sorted.map((p) => p.data.pubDate.getFullYear());
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min}${max}`;
})();
const pubWord = plural(sorted.length, ['публикация', 'публикации', 'публикаций']);
const catDescription = `${name} — ${sorted.length} ${pubWord}${yearsRange ? ` (${yearsRange})` : ''} в архиве ролевой группы «Иные Отражения».`;
--- ---
<BaseLayout title={`Категория: ${name}`}> <BaseLayout title={`Категория: ${name}`} description={catDescription}>
<section class="hero hero-compact" style={`--cat-color: ${catColor};`}> <section class="hero hero-compact" style={`--cat-color: ${catColor};`}>
<span class="hero-eyebrow" style={`color: ${catColor}; border-color: ${catColor};`}>Категория</span> <span class="hero-eyebrow" style={`color: ${catColor}; border-color: ${catColor};`}>Категория</span>
<h1 style={`background: linear-gradient(180deg, #ffffff 0%, ${catColor} 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;`}>{name}</h1> <h1 style={`background: linear-gradient(180deg, #ffffff 0%, ${catColor} 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;`}>{name}</h1>

View File

@@ -13,7 +13,7 @@ const oldestYear = posts.length ? posts[posts.length - 1].data.pubDate.getFullYe
const fmtDate = (d: Date) => const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }); d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
--- ---
<BaseLayout> <BaseLayout description="Главная ролевой группы «Иные Отражения»: новости, хроника публикаций с 2009 года и игровые миры — Дозоры, Амбер, Киндрэт, Ренессанс, Над бездной, Глубина, Warhammer 40k.">
<section class="hero"> <section class="hero">
<span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span> <span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span>
<h1>Иные<br/>Отражения</h1> <h1>Иные<br/>Отражения</h1>

View File

@@ -580,6 +580,7 @@ pre {
} }
/* ============================== COOKIE CONSENT ============================== */ /* ============================== COOKIE CONSENT ============================== */
.cookie-consent[hidden] { display: none !important; }
.cookie-consent { .cookie-consent {
position: fixed; position: fixed;
left: 1rem; left: 1rem;
@@ -723,6 +724,51 @@ pre {
.site-footer p { margin: 0 0 .3em; } .site-footer p { margin: 0 0 .3em; }
.site-footer a { color: var(--fg-muted); border-bottom-color: var(--border); } .site-footer a { color: var(--fg-muted); border-bottom-color: var(--border); }
.site-footer a:hover { color: var(--accent); border-bottom-color: var(--accent); } .site-footer a:hover { color: var(--accent); border-bottom-color: var(--accent); }
.site-footer .dev-credit {
margin-top: 0.9rem;
font-size: 0.8rem;
color: var(--fg-muted);
}
.site-footer .dev-credit a {
color: var(--fg-muted);
border-bottom: 1px dashed currentColor;
}
.site-footer .dev-credit a:hover { color: var(--accent); border-bottom-color: var(--accent); }
/* hhivp-credit — pill mark + slide-out text on hover */
.hhivp-credit {
display: inline-flex;
align-items: center;
vertical-align: middle;
text-decoration: none;
color: inherit;
border: 1px solid currentColor;
border-radius: 999px;
padding: 3px;
gap: 0;
line-height: 1;
opacity: 0.65;
transition: gap 240ms ease, padding-right 240ms ease, opacity 240ms ease;
}
.hhivp-credit:hover,
.hhivp-credit:focus-visible {
opacity: 1;
gap: 8px;
padding-right: 12px;
outline: none;
border-bottom: 1px solid currentColor;
}
.hhivp-credit-mark { display: inline-flex; flex-shrink: 0; }
.hhivp-credit-mark img { display: block; width: 20px; height: 20px; }
.hhivp-credit-text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 0;
font-size: 0.75rem;
transition: max-width 240ms ease;
}
.hhivp-credit:hover .hhivp-credit-text,
.hhivp-credit:focus-visible .hhivp-credit-text { max-width: 200px; }
.footer-ornament { .footer-ornament {
display: block; display: block;
width: 60px; width: 60px;