diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ba1c3f0..b95f2af 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -42,9 +42,35 @@ jobs: cd "$DEPLOY_PATH" 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 sleep 5 docker compose ps 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 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 diff --git a/package.json b/package.json index 3edabc2..ee4b0a9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", - "migrate": "node scripts/migrate-wp.mjs" + "migrate": "node scripts/migrate-wp.mjs", + "indexnow": "node scripts/indexnow.js" }, "dependencies": { "@astrojs/rss": "^4.0.12", diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..1c983fb --- /dev/null +++ b/public/.well-known/security.txt @@ -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 diff --git a/public/15455d9f2c7b473bb04336055b792ec9.txt b/public/15455d9f2c7b473bb04336055b792ec9.txt new file mode 100644 index 0000000..efd306f --- /dev/null +++ b/public/15455d9f2c7b473bb04336055b792ec9.txt @@ -0,0 +1 @@ +15455d9f2c7b473bb04336055b792ec9 \ No newline at end of file diff --git a/public/humans.txt b/public/humans.txt new file mode 100644 index 0000000..8ea225b --- /dev/null +++ b/public/humans.txt @@ -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 diff --git a/scripts/indexnow.js b/scripts/indexnow.js new file mode 100644 index 0000000..792561e --- /dev/null +++ b/scripts/indexnow.js @@ -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/.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>/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 = / /^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'), +]); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index b48875e..3b9a138 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,6 +1,6 @@ --- 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 SocialLinks from '../components/SocialLinks.astro'; import Analytics from '../components/Analytics.astro'; @@ -17,6 +17,35 @@ const pageTitle = title ? `${title} — ${SITE_TITLE}` : SITE_TITLE; const pageDesc = description || SITE_DESCRIPTION; const canonical = new URL(Astro.url.pathname, SITE_URL).toString(); 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', + name: SITE_TITLE, + url: SITE_URL, + inLanguage: SITE_LANG, + description: SITE_DESCRIPTION, + potentialAction: { + '@type': 'SearchAction', + target: `${SITE_URL}/?s={query}`, + 'query-input': 'required name=query', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'NewsMediaOrganization', + 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), + }, +]; --- @@ -46,6 +75,8 @@ const year = new Date().getFullYear(); +