Compare commits
3 Commits
main
...
3fa659fb7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa659fb7d | ||
|
|
291d5a5a98 | ||
|
|
86d2aa6c14 |
@@ -1,72 +0,0 @@
|
||||
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
59
.github/workflows/security.yml
vendored
@@ -1,59 +0,0 @@
|
||||
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'
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,13 +1,3 @@
|
||||
# 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
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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}['"]''',
|
||||
]
|
||||
17
CLAUDE.md
17
CLAUDE.md
@@ -75,9 +75,7 @@ src/
|
||||
└── consts.ts (SITE_TITLE, WORLDS, ANALYTICS, CATEGORY_COLORS, plural())
|
||||
|
||||
public/
|
||||
├── favicon.svg (брендовый знак на тёмном фоне, оптимизирован под 16-32px)
|
||||
├── favicon.ico (32×32 PNG-in-ICO, генерируется из favicon.svg)
|
||||
├── apple-touch-icon.png (180×180 для iOS home screen)
|
||||
├── favicon.svg
|
||||
├── logo.svg, logo-mark.svg
|
||||
├── og-image.png (1200×630 для расшаривания в TG/VK/Twitter)
|
||||
├── og-image.svg (исходник)
|
||||
@@ -88,8 +86,7 @@ public/
|
||||
|
||||
scripts/
|
||||
├── migrate-wp.mjs (одноразовый: _wp-export.json → src/content/*.md)
|
||||
├── 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`)
|
||||
└── build-og-image.mjs (sharp → public/og-image.png из SVG)
|
||||
|
||||
Dockerfile (multi-stage: node:22-alpine builds → nginx:1.29-alpine serves)
|
||||
nginx.conf (gzip, кэш _astro/ 1y, MIME application/rss+xml для feed)
|
||||
@@ -135,16 +132,6 @@ 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 тоже.
|
||||
|
||||
## 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 файлы
|
||||
|
||||
- `public/robots.txt` — статика, разрешено всё; явно перечислены GPTBot/ClaudeBot/Google-Extended/CCBot/PerplexityBot/anthropic-ai; ссылки на sitemap-index.xml и sitemap.txt
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
45
SECURITY.md
45
SECURITY.md
@@ -1,45 +0,0 @@
|
||||
# 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/
|
||||
@@ -12,8 +12,7 @@
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"migrate": "node scripts/migrate-wp.mjs",
|
||||
"indexnow": "node scripts/indexnow.js",
|
||||
"build:favicon": "node scripts/build-favicon.mjs"
|
||||
"indexnow": "node scripts/indexnow.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 655 B |
@@ -1,16 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="fg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#b794ff"/>
|
||||
<stop offset="1" stop-color="#6c4ed4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="bg" cx="50%" cy="38%" r="65%">
|
||||
<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 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<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" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 792 B After Width: | Height: | Size: 749 B |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -13,18 +13,9 @@ Host: https://anotherreflections.ru
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/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`);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ legacyId: "132"
|
||||
menuOrder: "4"
|
||||
pubDate: "2011-05-18T00:40:47+03:00"
|
||||
updatedDate: "2014-07-14T00:12:19+03:00"
|
||||
description: "Игровая вселенная по «Хроникам Амбера» Роджера Желязны: Истинный мир, его Отражения и принцы крови с непростой семейной историей."
|
||||
---
|
||||
|
||||
Тест
|
||||
|
||||
@@ -5,7 +5,6 @@ legacyId: "130"
|
||||
menuOrder: "5"
|
||||
pubDate: "2011-05-18T00:39:40+03:00"
|
||||
updatedDate: "2014-07-14T00:12:27+03:00"
|
||||
description: "Ролевая игра по циклу «Киндрэт» Алексея Пехова, Елены Бычковой и Натальи Турчаниновой: вампирские кланы ночной Столицы и их теневая политика."
|
||||
---
|
||||
|
||||
«Киндрэт. Кровные братья» — первая книга цикла Киндрэт известных российских писателей Алексея Пехова, Елены Бычковой и Натальи Турчаниновой, рассказывающая о жизни кланов вампиров. В цикл романов по миру ночной Столицы вошли 4 книги: «Кровные братья», «Колдун из клана смерти», «Основатель», «Новые боги».
|
||||
|
||||
@@ -5,7 +5,6 @@ legacyId: "124"
|
||||
menuOrder: "3"
|
||||
pubDate: "2011-05-18T00:27:35+03:00"
|
||||
updatedDate: "2014-07-14T00:12:09+03:00"
|
||||
description: "Главная ролевая игра «Иных Отражений» по миру «Дозоров» Сергея Лукьяненко и Владимира Васильева: Иные, Свет и Тьма, древний Договор."
|
||||
---
|
||||
|
||||
**Иные Отражения: Сумерки Дозоров**
|
||||
|
||||
@@ -5,7 +5,6 @@ legacyId: "339"
|
||||
menuOrder: "2"
|
||||
pubDate: "2013-07-09T13:29:47+03:00"
|
||||
updatedDate: "2025-06-07T02:31:08+03:00"
|
||||
description: "Партнёры и друзья ролевой группы «Иные Отражения»: ООО «АйТи Решения» (hhivp.com) — IT-услуги для бизнеса в Москве и области."
|
||||
---
|
||||
|
||||
[ООО "АйТи Решения"](https://hhivp.com) предоставляет полный спектр IT услуг на территории Москвы и Московской области, как частным лицам, так и представителям бизнеса. Мы способствуем развитию Вашего бизнеса и достижению самых смелых результатов!
|
||||
|
||||
@@ -5,7 +5,6 @@ legacyId: "137"
|
||||
menuOrder: "1"
|
||||
pubDate: "2011-05-18T00:50:11+03:00"
|
||||
updatedDate: "2014-07-14T00:10:31+03:00"
|
||||
description: "История ролевой группы «Иные Отражения»: основана в 2006 году как «Иные Миры», в 2007-м объединилась с проектом «Отражения»."
|
||||
---
|
||||
|
||||
**Ролевая группа "Иные Отражения"**
|
||||
|
||||
@@ -5,7 +5,6 @@ legacyId: "378"
|
||||
menuOrder: "6"
|
||||
pubDate: "2013-10-06T02:59:22+03:00"
|
||||
updatedDate: "2014-07-14T00:12:33+03:00"
|
||||
description: "«Ренессанс» — ролевая игра по миру «Дозоров» Сергея Лукьяненко: Иные, Сумрак и древний Договор в декорациях эпохи Возрождения."
|
||||
---
|
||||
|
||||
**Иные Отражения: Ренессанс**
|
||||
|
||||
@@ -24,21 +24,10 @@ 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}`,
|
||||
@@ -48,7 +37,6 @@ const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'NewsMediaOrganization',
|
||||
'@id': `${SITE_URL}/#org`,
|
||||
name: SITE_TITLE,
|
||||
url: SITE_URL,
|
||||
logo: logoUrl,
|
||||
@@ -69,8 +57,6 @@ const jsonLd = [
|
||||
<meta name="theme-color" content="#07090f" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<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:title" content={pageTitle} />
|
||||
@@ -96,28 +82,6 @@ const jsonLd = [
|
||||
<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 />
|
||||
|
||||
<!--
|
||||
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>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
@@ -152,14 +116,6 @@ const jsonLd = [
|
||||
<a href="/privacy/">Политика конфиденциальности</a> ·
|
||||
<a href="/kontakty/">Контакты</a>
|
||||
</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">Сделано в hhivp.com</span>
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<CookieConsent />
|
||||
|
||||
@@ -6,27 +6,13 @@ import { CATEGORY_COLORS } from '../consts';
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts');
|
||||
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 [
|
||||
...posts.map((p) => {
|
||||
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 } })),
|
||||
...posts.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const } })),
|
||||
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const } })),
|
||||
];
|
||||
}
|
||||
|
||||
const { entry, kind, sameYearDup } = Astro.props;
|
||||
const { entry, kind } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
// Буквица — только для постов с телом длиннее короткого порога.
|
||||
const bodyLen = entry.body?.length ?? 0;
|
||||
@@ -34,18 +20,10 @@ const useDropCap = kind === 'post' && bodyLen > 240;
|
||||
|
||||
const fmtDate = (d: Date) =>
|
||||
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
|
||||
title={seoTitle}
|
||||
description={entry.data.description || undefined}
|
||||
title={entry.data.title}
|
||||
description={kind === 'post' ? entry.data.description : undefined}
|
||||
ogType={kind === 'post' ? 'article' : 'website'}
|
||||
>
|
||||
<article class="post">
|
||||
|
||||
@@ -27,18 +27,8 @@ const sorted = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.va
|
||||
|
||||
const fmtDate = (d: Date) =>
|
||||
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}`} description={catDescription}>
|
||||
<BaseLayout title={`Категория: ${name}`}>
|
||||
<section class="hero hero-compact" style={`--cat-color: ${catColor};`}>
|
||||
<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>
|
||||
|
||||
@@ -13,7 +13,7 @@ const oldestYear = posts.length ? posts[posts.length - 1].data.pubDate.getFullYe
|
||||
const fmtDate = (d: Date) =>
|
||||
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
---
|
||||
<BaseLayout description="Главная ролевой группы «Иные Отражения»: новости, хроника публикаций с 2009 года и игровые миры — Дозоры, Амбер, Киндрэт, Ренессанс, Над бездной, Глубина, Warhammer 40k.">
|
||||
<BaseLayout>
|
||||
<section class="hero">
|
||||
<span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span>
|
||||
<h1>Иные<br/>Отражения</h1>
|
||||
|
||||
@@ -580,7 +580,6 @@ pre {
|
||||
}
|
||||
|
||||
/* ============================== COOKIE CONSENT ============================== */
|
||||
.cookie-consent[hidden] { display: none !important; }
|
||||
.cookie-consent {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
@@ -724,51 +723,6 @@ pre {
|
||||
.site-footer p { margin: 0 0 .3em; }
|
||||
.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 .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 {
|
||||
display: block;
|
||||
width: 60px;
|
||||
|
||||
Reference in New Issue
Block a user