- Главная: тихая кнопка «Помахать» (web/js/wave.js), общий счётчик для всех посетителей - server/server.mjs: мини-API (/api/waves, /api/wave), счётчик хранится вне репозитория (/var/lib/vizitka-site/waves.json), лимит раз в секунду с IP, проверка Origin - deploy: systemd-сервис vizitka-api, проксирование /api в nginx, setup-server.sh ставит nodejs - CI: после git pull перезапускает API - Домен https://me.ibrash.duckdns.org вместо заглушки, исправлен пробел в шапке - README и заметки обновлены Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
# CI: при пуше в main заходим на сервер по ssh и делаем git pull.
|
# CI: при пуше в main заходим на сервер по ssh, делаем git pull и перезапускаем API счётчика
|
||||||
|
# (счётчик лежит в /var/lib/vizitka-site вне репозитория и при этом не сбрасывается).
|
||||||
# Всё остальное уже настроено на сервере (nginx раздаёт /opt/vizitka-site/web, см. deploy/setup-server.sh).
|
# Всё остальное уже настроено на сервере (nginx раздаёт /opt/vizitka-site/web, см. deploy/setup-server.sh).
|
||||||
# Нужен один секрет: DEPLOY_SSH_KEY (приватный ключ с доступом к серверу).
|
# Нужен один секрет: DEPLOY_SSH_KEY (приватный ключ с доступом к серверу).
|
||||||
name: deploy
|
name: deploy
|
||||||
@@ -23,4 +24,4 @@ jobs:
|
|||||||
# отпечаток ключа сервера закреплён: подмена сервера не пройдёт
|
# отпечаток ключа сервера закреплён: подмена сервера не пройдёт
|
||||||
echo "192.168.10.112 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHFLwAGOvgF5ZSBiZ9YRe78gT9HN1MeESQIaM6Aou/Ud" > "$HOME/.ssh/known_hosts"
|
echo "192.168.10.112 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHFLwAGOvgF5ZSBiZ9YRe78gT9HN1MeESQIaM6Aou/Ud" > "$HOME/.ssh/known_hosts"
|
||||||
ssh -i "$HOME/.ssh/id_deploy" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
ssh -i "$HOME/.ssh/id_deploy" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||||
root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only && git log -1 --oneline'
|
root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only && { systemctl restart vizitka-api || echo "сервис vizitka-api пока не настроен (deploy/setup-server.sh)"; } && git log -1 --oneline'
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
- Репозиторий: `git@git.ibrash.duckdns.org:aibrash/vizitka-site.git` (Gitea 1.27), ветка `main`
|
- Репозиторий: `git@git.ibrash.duckdns.org:aibrash/vizitka-site.git` (Gitea 1.27), ветка `main`
|
||||||
- Сервер: `root@192.168.10.112` (Debian 12, имя хоста `visitka-site`): клон репозитория в `/opt/vizitka-site`, nginx отдаёт его папку `web/`, пока по HTTP внутри сети
|
- Сервер: `root@192.168.10.112` (Debian 12, имя хоста `visitka-site`): клон репозитория в `/opt/vizitka-site`, nginx отдаёт его папку `web/`, пока по HTTP внутри сети
|
||||||
- Публичного домена пока нет: в OG-тегах и `sitemap.xml` стоит заглушка `https://example.com` (как заменить: раздел 5)
|
- Домен: `https://me.ibrash.duckdns.org` (HTTPS терминирует отдельный прокси на openresty перед сервером). Он прописан в OG-тегах, `sitemap.xml` и `robots.txt`; как сменить: раздел 5
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ web/ то, что уходит на сервер (nginx root)
|
|||||||
js/card.js заполняет главную из config.json
|
js/card.js заполняет главную из config.json
|
||||||
js/nav.js верхнее меню из config.pages (скрывает draft), подсветка текущего раздела
|
js/nav.js верхнее меню из config.pages (скрывает draft), подсветка текущего раздела
|
||||||
js/count.js числа «набегают» при появлении
|
js/count.js числа «набегают» при появлении
|
||||||
|
js/wave.js кнопка «Помахать» со счётчиком на главной (говорит с /api)
|
||||||
arch/ список проектов + arch/<проект>/ (три страницы)
|
arch/ список проектов + arch/<проект>/ (три страницы)
|
||||||
projects/ список + projects/amans-world/ + projects/homelab/
|
projects/ список + projects/amans-world/ + projects/homelab/
|
||||||
training/ work/ fixed/ ЧЕРНОВИКИ будущих страниц (draft, noindex), в меню не видны
|
training/ work/ fixed/ ЧЕРНОВИКИ будущих страниц (draft, noindex), в меню не видны
|
||||||
@@ -79,8 +80,9 @@ web/ то, что уходит на сервер (nginx root)
|
|||||||
fonts/ woff2 Onest и лицензия
|
fonts/ woff2 Onest и лицензия
|
||||||
404.html, favicon.svg, robots.txt, sitemap.xml
|
404.html, favicon.svg, robots.txt, sitemap.xml
|
||||||
templates/page.html шаблон новой страницы
|
templates/page.html шаблон новой страницы
|
||||||
|
server/ server.mjs: мини-API счётчика «Помахать» (Node, без зависимостей)
|
||||||
tools/ gen_arch.py, gen_projects.py: генераторы страниц архитектуры и проектов
|
tools/ gen_arch.py, gen_projects.py: генераторы страниц архитектуры и проектов
|
||||||
deploy/ nginx-site.conf, setup-server.sh (разовая настройка сервера)
|
deploy/ nginx-site.conf, vizitka-api.service, setup-server.sh (разовая настройка сервера)
|
||||||
.gitea/workflows/deploy.yml CI
|
.gitea/workflows/deploy.yml CI
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -88,6 +90,15 @@ deploy/ nginx-site.conf, setup-server.sh (разовая наст
|
|||||||
Содержимое берётся из `web/config.json` (имя, `photo`, `status`, `tagline`, `facts`, `tags`, `cta`, `email`, `socials`). Если `photo` пустое, показываются инициалы. До загрузки конфига страница скрыта (`html.js:not(.ready) body[data-hide] .page`), чтобы вёрстка не прыгала; без JS всё видно сразу.
|
Содержимое берётся из `web/config.json` (имя, `photo`, `status`, `tagline`, `facts`, `tags`, `cta`, `email`, `socials`). Если `photo` пустое, показываются инициалы. До загрузки конфига страница скрыта (`html.js:not(.ready) body[data-hide] .page`), чтобы вёрстка не прыгала; без JS всё видно сразу.
|
||||||
Хоумлаб на главной не упоминается **намеренно** (так просил владелец).
|
Хоумлаб на главной не упоминается **намеренно** (так просил владелец).
|
||||||
|
|
||||||
|
### Кнопка «Помахать» (интерактив на главной)
|
||||||
|
Тихая «призрачная» кнопка внизу карточки «Давай общаться»: прозрачная, тонкая рамка, цвет `--muted`, не конкурирует с ссылками. При клике ладонь машет (`@keyframes wave`, как в приветствии), число плавно докручивается, всплывает маленькое «+1». Счётчик общий для всех посетителей.
|
||||||
|
- Фронтенд: `web/js/wave.js` и блок `.wave-row` в `index.html`. Если API недоступно (например, при просмотре через `python3 -m http.server`), блок прячется (`visibility:hidden`, место зарезервировано, чтобы вёрстка не прыгала).
|
||||||
|
- Бэкенд: `server/server.mjs`, `GET /api/waves` и `POST /api/wave`. Лимит: не чаще раза в секунду с одного IP и не больше 300 в минуту на всех; POST принимается только с разрешённых сайтов (`ALLOWED_ORIGINS`); тело запроса игнорируется, от клиента данные не берутся.
|
||||||
|
- **Счётчик не сбрасывается:** хранится в `/var/lib/vizitka-site/waves.json` (создаётся systemd, вне репозитория), пишется атомарно, перед каждой записью предыдущее значение копируется в `waves.json.bak`, при остановке сервиса значение сохраняется. `git pull`, пуши и перезапуски сервера его не трогают. Не клади файл счётчика внутрь репозитория.
|
||||||
|
- Сервис `vizitka-api` (systemd, `DynamicUser`, песочница, `MemoryMax=96M`), настройки в `/etc/vizitka-api.env` (создаются один раз и потом не перезаписываются), nginx проксирует `/api/` на `127.0.0.1:8787` и передаёт IP посетителя (`X-Client-IP`).
|
||||||
|
- Локальная проверка: `DATA_FILE=/tmp/waves.json STATIC_DIR=web PORT=8791 node server/server.mjs` и открыть `http://localhost:8791`.
|
||||||
|
- Править текст кнопки и числа: прямо в `web/index.html` и `web/js/wave.js`.
|
||||||
|
|
||||||
### Меню и страницы
|
### Меню и страницы
|
||||||
Меню рисует `js/nav.js` из `config.json` → `pages`. На главной оно появляется, только если есть опубликованные страницы. Внутренние страницы всегда показывают имя и пункты. На узких экранах фамилия в шапке прячется (чтобы влезли все пункты).
|
Меню рисует `js/nav.js` из `config.json` → `pages`. На главной оно появляется, только если есть опубликованные страницы. Внутренние страницы всегда показывают имя и пункты. На узких экранах фамилия в шапке прячется (чтобы влезли все пункты).
|
||||||
|
|
||||||
@@ -142,15 +153,15 @@ magick src.png -strip -resize 640x640\> -quality 74 имя-sm.webp
|
|||||||
## 5. Деплой и CI
|
## 5. Деплой и CI
|
||||||
|
|
||||||
### Схема (простая)
|
### Схема (простая)
|
||||||
`push в main` → Gitea Actions (`.gitea/workflows/deploy.yml`) заходит по ssh на `root@192.168.10.112` и делает `git pull` в `/opt/vizitka-site` → nginx раздаёт `/opt/vizitka-site/web`, перезапуск не нужен. Сборки нет: что лежит в `web/`, то и на сайте.
|
`push в main` → Gitea Actions (`.gitea/workflows/deploy.yml`) заходит по ssh на `root@192.168.10.112`, делает `git pull` в `/opt/vizitka-site` и перезапускает API счётчика (`systemctl restart vizitka-api`) → nginx раздаёт `/opt/vizitka-site/web`. Для статики перезапуск не нужен; сборки нет: что лежит в `web/`, то и на сайте.
|
||||||
|
|
||||||
Репозиторий публичный, поэтому сервер клонирует и обновляет его по HTTPS без ключей и паролей: `https://git.ibrash.duckdns.org/aibrash/vizitka-site.git`. Если репозиторий станет закрытым, на сервер придётся добавить deploy-ключ (в Gitea: репозиторий → Настройки → Deploy keys) или токен.
|
Репозиторий публичный, поэтому сервер клонирует и обновляет его по HTTPS без ключей и паролей: `https://git.ibrash.duckdns.org/aibrash/vizitka-site.git`. Если репозиторий станет закрытым, на сервер придётся добавить deploy-ключ (в Gitea: репозиторий → Настройки → Deploy keys) или токен.
|
||||||
|
|
||||||
### Что настроено на сервере (сделано, `deploy/setup-server.sh`)
|
### Что настроено на сервере (сделано, `deploy/setup-server.sh`)
|
||||||
```bash
|
```bash
|
||||||
./deploy/setup-server.sh root@192.168.10.112 # ставит nginx и git, клонирует репозиторий в /opt/vizitka-site, подключает deploy/nginx-site.conf
|
./deploy/setup-server.sh root@192.168.10.112 # ставит nginx, git и nodejs, клонирует репозиторий в /opt/vizitka-site, включает сервис vizitka-api и подключает deploy/nginx-site.conf
|
||||||
```
|
```
|
||||||
Скрипт безопасно запускать повторно: если клон уже есть, он делает `git pull`, и заново применяет конфиг nginx (там `root /opt/vizitka-site/web`, сжатие, кэш, 404, строгий CSP, скрытые файлы закрыты).
|
Скрипт безопасно запускать повторно: если клон уже есть, он делает `git pull`, обновляет systemd-юнит и заново применяет конфиг nginx (`/etc/vizitka-api.env` не перезаписывается, счётчик не трогается) (там `root /opt/vizitka-site/web`, сжатие, кэш, 404, строгий CSP, скрытые файлы закрыты).
|
||||||
Если нужно поменять конфиг nginx: правь `deploy/nginx-site.conf` и снова запусти скрипт. Если `/opt/vizitka-site` уже был не репозиторием, скрипт откладывает его в `/opt/vizitka-site.old-<время>`, а не удаляет (одна такая папка там сейчас лежит, это старая выкладка через rsync, её можно удалить).
|
Если нужно поменять конфиг nginx: правь `deploy/nginx-site.conf` и снова запусти скрипт. Если `/opt/vizitka-site` уже был не репозиторием, скрипт откладывает его в `/opt/vizitka-site.old-<время>`, а не удаляет (одна такая папка там сейчас лежит, это старая выкладка через rsync, её можно удалить).
|
||||||
|
|
||||||
### Что нужно для работы CI
|
### Что нужно для работы CI
|
||||||
@@ -166,11 +177,11 @@ ssh root@192.168.10.112 'cd /opt/vizitka-site && git pull --ff-only'
|
|||||||
|
|
||||||
### HTTPS и домен (позже)
|
### HTTPS и домен (позже)
|
||||||
Сейчас сервер отдаёт HTTP только внутри сети. Для публичного доступа лучше поставить перед ним обратный прокси (Caddy или nginx на отдельном хосте с сертификатом) или Cloudflare Tunnel, а сам сервер с сайтом не выставлять наружу напрямую рядом с остальными домашними устройствами.
|
Сейчас сервер отдаёт HTTP только внутри сети. Для публичного доступа лучше поставить перед ним обратный прокси (Caddy или nginx на отдельном хосте с сертификатом) или Cloudflare Tunnel, а сам сервер с сайтом не выставлять наружу напрямую рядом с остальными домашними устройствами.
|
||||||
Заглушка `https://example.com` лежит в OG-тегах всех `.html`, в `web/sitemap.xml` и `web/robots.txt`. Раньше домен подставлял скрипт выкладки, теперь выкладки-скрипта нет, поэтому когда выберешь домен, замени один раз и закоммить:
|
Домен `me.ibrash.duckdns.org` уже подставлен в OG-теги, `sitemap.xml`, `robots.txt` и в `tools/`. Если домен сменится, замени один раз и закоммить (и поправь `ORIGINS`/`ALLOWED_ORIGINS` для API: `/etc/vizitka-api.env`, потом `systemctl restart vizitka-api`):
|
||||||
```bash
|
```bash
|
||||||
grep -rlF 'https://example.com' web tools | xargs sed -i 's|https://example.com|https://ТВОЙ.ДОМЕН|g'
|
grep -rlF 'https://me.ibrash.duckdns.org' web tools | xargs sed -i 's|https://me.ibrash.duckdns.org|https://НОВЫЙ.ДОМЕН|g'
|
||||||
```
|
```
|
||||||
(в `tools/` она тоже есть, чтобы генераторы не вернули заглушку).
|
Реальный IP посетителя nginx берёт из `X-Forwarded-For` (`set_real_ip_from` для частных сетей в `deploy/nginx-site.conf`): если передний прокси поменяется и не будет присылать этот заголовок, лимит «раз в секунду» станет общим на всех, проверь по `access.log`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -205,7 +216,7 @@ python3 -m http.server 8787 --directory web # локальный просм
|
|||||||
|
|
||||||
**Что осталось сделать (на момент написания).**
|
**Что осталось сделать (на момент написания).**
|
||||||
1. Убедиться, что в Gitea есть runner и CI срабатывает на пуш (секрет `DEPLOY_SSH_KEY` уже добавлен). Удалить с сервера `/opt/vizitka-site.old-*`.
|
1. Убедиться, что в Gitea есть runner и CI срабатывает на пуш (секрет `DEPLOY_SSH_KEY` уже добавлен). Удалить с сервера `/opt/vizitka-site.old-*`.
|
||||||
2. Выбрать домен, включить HTTPS через прокси или туннель, заменить заглушку `example.com` (раздел 5).
|
2. Проверить, что кнопка «Помахать» в проде получает реальные IP посетителей (смотреть `/var/log/nginx/access.log`) и что счётчик переживает перезапуск (`systemctl restart vizitka-api`).
|
||||||
3. Реальные ники (в `config.json` у Telegram и Instagram стоят короткие ники и их handle могут быть временными), проверка текстов про игру и про хоумлаб.
|
3. Реальные ники (в `config.json` у Telegram и Instagram стоят короткие ники и их handle могут быть временными), проверка текстов про игру и про хоумлаб.
|
||||||
4. Страницы Тренировки, Работа, Фикс (черновики уже лежат в `web/`).
|
4. Страницы Тренировки, Работа, Фикс (черновики уже лежат в `web/`).
|
||||||
5. Фото сервера для хоумлаба вместо иллюстрации, больше кадров игры (Backrooms, песочница, паркур).
|
5. Фото сервера для хоумлаба вместо иллюстрации, больше кадров игры (Backrooms, песочница, паркур).
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ server {
|
|||||||
root /opt/vizitka-site/web;
|
root /opt/vizitka-site/web;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# реальный IP посетителя из X-Forwarded-For, если запрос пришёл от прокси из локальной сети (для лимита в API)
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
|
set_real_ip_from 127.0.0.0/8;
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
|
||||||
# сжатие
|
# сжатие
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -20,6 +28,16 @@ server {
|
|||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
|
|
||||||
|
# API кнопки «Помахать» (Node, systemd-сервис vizitka-api), счётчик хранится вне репозитория
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8787;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Client-IP $remote_addr;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
client_max_body_size 1k;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-6
@@ -1,19 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Разовая подготовка сервера: nginx, git, клон репозитория в /opt/vizitka-site. Безопасно запускать повторно.
|
# Разовая подготовка сервера: nginx, git, nodejs, клон репозитория в /opt/vizitka-site, сервис API счётчика.
|
||||||
# Использование: ./deploy/setup-server.sh root@192.168.10.112
|
# Безопасно запускать повторно. Использование: ./deploy/setup-server.sh root@192.168.10.112
|
||||||
# Обновления потом приходят через `git pull` (его делает CI, см. .gitea/workflows/deploy.yml).
|
# Обновления потом приходят через `git pull` (его делает CI, см. .gitea/workflows/deploy.yml).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TARGET="${1:?укажите сервер: ./deploy/setup-server.sh root@192.168.10.112}"
|
TARGET="${1:?укажите сервер: ./deploy/setup-server.sh root@192.168.10.112}"
|
||||||
REPO_URL="${REPO_URL:-https://git.ibrash.duckdns.org/aibrash/vizitka-site.git}" # репозиторий публичный: ключи не нужны
|
REPO_URL="${REPO_URL:-https://git.ibrash.duckdns.org/aibrash/vizitka-site.git}" # репозиторий публичный: ключи не нужны
|
||||||
|
# откуда разрешены POST к API (домен сайта и адрес в локальной сети)
|
||||||
|
ORIGINS="${ORIGINS:-https://me.ibrash.duckdns.org,http://me.ibrash.duckdns.org,http://192.168.10.112}"
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
scp -q "$HERE/nginx-site.conf" "$TARGET:/tmp/vizitka-nginx.conf"
|
scp -q "$HERE/nginx-site.conf" "$TARGET:/tmp/vizitka-nginx.conf"
|
||||||
ssh "$TARGET" "REPO_URL='$REPO_URL' bash -s" <<'REMOTE'
|
scp -q "$HERE/vizitka-api.service" "$TARGET:/tmp/vizitka-api.service"
|
||||||
|
ssh "$TARGET" "REPO_URL='$REPO_URL' ORIGINS='$ORIGINS' bash -s" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
if ! command -v nginx >/dev/null || ! command -v git >/dev/null; then
|
if ! command -v nginx >/dev/null || ! command -v git >/dev/null || ! command -v node >/dev/null; then
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq nginx git >/dev/null
|
apt-get install -y -qq nginx git nodejs >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DIR=/opt/vizitka-site
|
DIR=/opt/vizitka-site
|
||||||
@@ -24,11 +27,26 @@ else
|
|||||||
git clone "$REPO_URL" "$DIR"
|
git clone "$REPO_URL" "$DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# сервис API: настройки лежат вне репозитория; существующий файл не перезаписываем
|
||||||
|
if [ ! -f /etc/vizitka-api.env ]; then
|
||||||
|
cat > /etc/vizitka-api.env <<ENV
|
||||||
|
PORT=8787
|
||||||
|
HOST=127.0.0.1
|
||||||
|
DATA_FILE=/var/lib/vizitka-site/waves.json
|
||||||
|
ALLOWED_ORIGINS=$ORIGINS
|
||||||
|
TRUST_PROXY=1
|
||||||
|
ENV
|
||||||
|
fi
|
||||||
|
install -m 644 /tmp/vizitka-api.service /etc/systemd/system/vizitka-api.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable vizitka-api >/dev/null 2>&1
|
||||||
|
systemctl restart vizitka-api
|
||||||
|
|
||||||
install -m 644 /tmp/vizitka-nginx.conf /etc/nginx/sites-available/vizitka-site
|
install -m 644 /tmp/vizitka-nginx.conf /etc/nginx/sites-available/vizitka-site
|
||||||
ln -sf /etc/nginx/sites-available/vizitka-site /etc/nginx/sites-enabled/vizitka-site
|
ln -sf /etc/nginx/sites-available/vizitka-site /etc/nginx/sites-enabled/vizitka-site
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl enable --now nginx >/dev/null 2>&1
|
systemctl enable --now nginx >/dev/null 2>&1
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
echo "готово: nginx $(nginx -v 2>&1 | cut -d/ -f2), сайт: $DIR/web (коммит $(git -C "$DIR" rev-parse --short HEAD))"
|
echo "готово: nginx $(nginx -v 2>&1 | cut -d/ -f2), node $(node --version), сайт $DIR/web (коммит $(git -C "$DIR" rev-parse --short HEAD)), API: $(systemctl is-active vizitka-api)"
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=vizitka-site API (счётчик «Помахать»)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# отдельный временный пользователь без прав; каталог /var/lib/vizitka-site создаётся сам и переживает пуши и перезапуски
|
||||||
|
DynamicUser=yes
|
||||||
|
StateDirectory=vizitka-site
|
||||||
|
EnvironmentFile=/etc/vizitka-api.env
|
||||||
|
ExecStart=/usr/bin/node /opt/vizitka-site/server/server.mjs
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# песочница
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryMax=96M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Мини-API для кнопки «Помахать» на главной: общий счётчик для всех посетителей.
|
||||||
|
// GET /api/waves -> { "count": N }
|
||||||
|
// POST /api/wave -> { "count": N+1 } (не чаще раза в секунду с одного IP)
|
||||||
|
// Без зависимостей, Node 18+. Счётчик хранится в файле ВНЕ репозитория (по умолчанию /var/lib/vizitka-site/waves.json),
|
||||||
|
// поэтому git pull, пуши и перезапуски его не сбрасывают.
|
||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = Number(process.env.PORT || 8787);
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
|
const DATA_FILE = process.env.DATA_FILE || path.join(HERE, 'data', 'waves.json');
|
||||||
|
// Откуда разрешены POST (через запятую), например: https://me.ibrash.duckdns.org,http://192.168.10.112
|
||||||
|
const ORIGINS = (process.env.ALLOWED_ORIGINS || '').split(',').map((s) => s.trim().replace(/\/+$/, '')).filter(Boolean);
|
||||||
|
// 1, если API стоит за nginx на этой же машине: тогда IP посетителя берём из X-Client-IP
|
||||||
|
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
|
||||||
|
// Только для локальной проверки: раздавать статику из этого каталога
|
||||||
|
const STATIC_DIR = process.env.STATIC_DIR ? path.resolve(process.env.STATIC_DIR) : '';
|
||||||
|
|
||||||
|
/* ---------- счётчик: хранение ---------- */
|
||||||
|
const readCount = (file) => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(fs.readFileSync(file, 'utf8')).count;
|
||||||
|
return Number.isSafeInteger(v) && v >= 0 ? v : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
// основной файл, при порче берём резервную копию
|
||||||
|
let count = readCount(DATA_FILE) ?? readCount(DATA_FILE + '.bak') ?? 0;
|
||||||
|
|
||||||
|
let dirty = false, timer = null;
|
||||||
|
function save() {
|
||||||
|
if (!dirty) return;
|
||||||
|
dirty = false;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||||
|
if (fs.existsSync(DATA_FILE)) fs.copyFileSync(DATA_FILE, DATA_FILE + '.bak'); // предыдущее значение на всякий случай
|
||||||
|
const tmp = DATA_FILE + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ count }));
|
||||||
|
fs.renameSync(tmp, DATA_FILE); // атомарно: файл не побьётся при падении
|
||||||
|
} catch (e) { console.error('не удалось сохранить счётчик:', e.message); dirty = true; }
|
||||||
|
}
|
||||||
|
const scheduleSave = () => { dirty = true; if (!timer) timer = setTimeout(() => { timer = null; save(); }, 1000); };
|
||||||
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { save(); process.exit(0); });
|
||||||
|
process.on('exit', save);
|
||||||
|
|
||||||
|
/* ---------- лимиты ---------- */
|
||||||
|
const lastHit = new Map(); // ip -> время последнего махания
|
||||||
|
let minuteStart = Date.now(), minuteHits = 0;
|
||||||
|
const MAX_PER_MINUTE = 300; // общий предел на всех: защита от флуда с разных адресов
|
||||||
|
function allowWave(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - minuteStart > 60_000) { minuteStart = now; minuteHits = 0; }
|
||||||
|
if (minuteHits >= MAX_PER_MINUTE) return false;
|
||||||
|
const last = lastHit.get(ip);
|
||||||
|
if (last !== undefined && now - last < 1000) return false;
|
||||||
|
if (lastHit.size > 20000) lastHit.clear();
|
||||||
|
lastHit.set(ip, now); minuteHits++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setInterval(() => { const cut = Date.now() - 60_000; for (const [k, t] of lastHit) if (t < cut) lastHit.delete(k); }, 30_000).unref();
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
const remote = req.socket.remoteAddress || '';
|
||||||
|
const loop = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
||||||
|
if (TRUST_PROXY && loop) {
|
||||||
|
const h = req.headers['x-client-ip'];
|
||||||
|
if (typeof h === 'string' && h.length < 64) return h.trim();
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- HTTP ---------- */
|
||||||
|
function send(res, code, body, extra = {}) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml' };
|
||||||
|
function serveStatic(res, pathname) {
|
||||||
|
let rel; try { rel = decodeURIComponent(pathname); } catch { return send(res, 400, { error: 'bad_request' }); }
|
||||||
|
let file = path.join(STATIC_DIR, path.normalize(rel));
|
||||||
|
if (file !== STATIC_DIR && !file.startsWith(STATIC_DIR + path.sep)) return send(res, 403, { error: 'forbidden' });
|
||||||
|
try { if (fs.statSync(file).isDirectory()) file = path.join(file, 'index.html'); } catch { /* дальше 404 */ }
|
||||||
|
fs.readFile(file, (err, buf) => {
|
||||||
|
if (err) return fs.readFile(path.join(STATIC_DIR, '404.html'), (_, nf) => { res.writeHead(404, { 'Content-Type': MIME['.html'] }); res.end(nf || 'not found'); });
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff' }); res.end(buf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let url; try { url = new URL(req.url, 'http://localhost'); } catch { return send(res, 400, { error: 'bad_request' }); }
|
||||||
|
const { pathname } = url;
|
||||||
|
|
||||||
|
if (!pathname.startsWith('/api/')) {
|
||||||
|
if (STATIC_DIR && (req.method === 'GET' || req.method === 'HEAD')) return serveStatic(res, pathname);
|
||||||
|
return send(res, 404, { error: 'not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/waves' && req.method === 'GET') return send(res, 200, { count });
|
||||||
|
|
||||||
|
if (pathname === '/api/wave' && req.method === 'POST') {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (ORIGINS.length && origin && !ORIGINS.includes(origin)) return send(res, 403, { error: 'forbidden_origin' });
|
||||||
|
// тело игнорируем целиком: от клиента не принимаем никаких данных; читаем с лимитом, чтобы не копить
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (c) => { size += c.length; if (size > 1024) req.destroy(); });
|
||||||
|
req.on('error', () => {});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!allowWave(clientIp(req))) return send(res, 429, { error: 'too_many_requests' }, { 'Retry-After': '1' });
|
||||||
|
count += 1; scheduleSave();
|
||||||
|
send(res, 200, { count });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(res, 404, { error: 'not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.headersTimeout = 10_000;
|
||||||
|
server.requestTimeout = 10_000;
|
||||||
|
server.listen(PORT, HOST, () => console.log(`api: http://${HOST}:${PORT}, счётчик ${count}, файл ${DATA_FILE}`));
|
||||||
+4
-4
@@ -18,13 +18,13 @@ def head(title, desc, og, depth=1):
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="{E(title)}: Аман Ибраш">
|
<meta property="og:title" content="{E(title)}: Аман Ибраш">
|
||||||
<meta property="og:description" content="{E(desc)}">
|
<meta property="og:description" content="{E(desc)}">
|
||||||
<meta property="og:image" content="https://example.com/og/{og}">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/{og}">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="{E(title)}: Аман Ибраш">
|
<meta name="twitter:title" content="{E(title)}: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="{E(desc)}">
|
<meta name="twitter:description" content="{E(desc)}">
|
||||||
<meta name="twitter:image" content="https://example.com/og/{og}">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/{og}">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
@@ -203,7 +203,7 @@ open(W + 'arch/index.html', 'w').write(idx)
|
|||||||
sm_path = W + 'sitemap.xml'
|
sm_path = W + 'sitemap.xml'
|
||||||
sm = open(sm_path).read() if os.path.exists(sm_path) else '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n</urlset>\n'
|
sm = open(sm_path).read() if os.path.exists(sm_path) else '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n</urlset>\n'
|
||||||
for u in ['/', '/arch/'] + [f"/arch/{c['slug']}/" for c in CASES]:
|
for u in ['/', '/arch/'] + [f"/arch/{c['slug']}/" for c in CASES]:
|
||||||
if f'<loc>https://example.com{u}</loc>' not in sm:
|
if f'<loc>https://me.ibrash.duckdns.org{u}</loc>' not in sm:
|
||||||
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
|
sm = sm.replace('</urlset>', f' <url><loc>https://me.ibrash.duckdns.org{u}</loc></url>\n</urlset>')
|
||||||
open(sm_path, 'w').write(sm)
|
open(sm_path, 'w').write(sm)
|
||||||
print('arch pages:', [c['slug'] for c in CASES])
|
print('arch pages:', [c['slug'] for c in CASES])
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ open(W + 'projects/homelab/index.html', 'w').write(hl)
|
|||||||
sm_path = W + 'sitemap.xml'
|
sm_path = W + 'sitemap.xml'
|
||||||
sm = open(sm_path).read()
|
sm = open(sm_path).read()
|
||||||
for u in ['/projects/', '/projects/amans-world/', '/projects/homelab/']:
|
for u in ['/projects/', '/projects/amans-world/', '/projects/homelab/']:
|
||||||
if f'<loc>https://example.com{u}</loc>' not in sm:
|
if f'<loc>https://me.ibrash.duckdns.org{u}</loc>' not in sm:
|
||||||
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
|
sm = sm.replace('</urlset>', f' <url><loc>https://me.ibrash.duckdns.org{u}</loc></url>\n</urlset>')
|
||||||
open(sm_path, 'w').write(sm)
|
open(sm_path, 'w').write(sm)
|
||||||
print('projects pages: ok')
|
print('projects pages: ok')
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Выставочный павильон: Аман Ибраш">
|
<meta property="og:title" content="Выставочный павильон: Аман Ибраш">
|
||||||
<meta property="og:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
|
<meta property="og:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
|
||||||
<meta property="og:image" content="https://example.com/og/arch-exhibition-pavilion.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-exhibition-pavilion.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Выставочный павильон: Аман Ибраш">
|
<meta name="twitter:title" content="Выставочный павильон: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
|
<meta name="twitter:description" content="Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/arch-exhibition-pavilion.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-exhibition-pavilion.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
+2
-2
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Архитектура: Аман Ибраш">
|
<meta property="og:title" content="Архитектура: Аман Ибраш">
|
||||||
<meta property="og:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
|
<meta property="og:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
|
||||||
<meta property="og:image" content="https://example.com/og/arch.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Архитектура: Аман Ибраш">
|
<meta name="twitter:title" content="Архитектура: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
|
<meta name="twitter:description" content="Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/arch.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
|
<meta property="og:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
|
||||||
<meta property="og:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
|
<meta property="og:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
|
||||||
<meta property="og:image" content="https://example.com/og/arch-loft-complex.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-loft-complex.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
|
<meta name="twitter:title" content="Жилой комплекс «Лофт»: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
|
<meta name="twitter:description" content="Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/arch-loft-complex.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-loft-complex.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Тихий двор: Аман Ибраш">
|
<meta property="og:title" content="Тихий двор: Аман Ибраш">
|
||||||
<meta property="og:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
|
<meta property="og:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
|
||||||
<meta property="og:image" content="https://example.com/og/arch-quiet-courtyard.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/arch-quiet-courtyard.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Тихий двор: Аман Ибраш">
|
<meta name="twitter:title" content="Тихий двор: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
|
<meta name="twitter:description" content="Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/arch-quiet-courtyard.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/arch-quiet-courtyard.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
@@ -305,3 +305,19 @@ a.tile:hover { transform: translateY(-4px); box-shadow: 0 16px 36px rgba(18, 26,
|
|||||||
/* последняя плитка: «список открытый» */
|
/* последняя плитка: «список открытый» */
|
||||||
.tile--more { background: transparent; box-shadow: none; border: 2px dashed var(--line); align-content: center; }
|
.tile--more { background: transparent; box-shadow: none; border: 2px dashed var(--line); align-content: center; }
|
||||||
.tile--more h3 { color: var(--accent); }
|
.tile--more h3 { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ===== «Помахать»: тихая кнопка со счётчиком (не выделяется, повторяет приветствие 👋) ===== */
|
||||||
|
.wave-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; margin-top: .3rem; padding-top: .9rem; border-top: 1px dashed var(--line); min-height: 3.9rem; }
|
||||||
|
.wave-row.off { visibility: hidden; }
|
||||||
|
.wave-btn { position: relative; display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; padding: 0 1rem; border: 1px solid var(--line); border-radius: 99px; background: transparent; color: var(--muted); font: 600 .92rem var(--font); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color .2s, border-color .2s, background .2s, transform .1s; }
|
||||||
|
.wave-btn:hover { color: var(--ink); border-color: var(--accent); background: var(--card); }
|
||||||
|
.wave-btn:active { transform: scale(.96); }
|
||||||
|
.wave-btn[disabled] { cursor: default; }
|
||||||
|
.wave-em { display: inline-block; transform-origin: 70% 70%; }
|
||||||
|
.wave-btn.waving .wave-em { animation: wave 0.9s; }
|
||||||
|
.wave-count { font-size: .88rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.wave-count b { color: var(--ink); font-weight: 700; }
|
||||||
|
.wave-plus { position: absolute; right: .6rem; top: -.2rem; font-size: .85rem; font-weight: 700; color: var(--accent); pointer-events: none; animation: wave-plus .9s ease-out forwards; }
|
||||||
|
@keyframes wave-plus { from { opacity: 0; transform: translateY(.3rem); } 25% { opacity: 1; } to { opacity: 0; transform: translateY(-1.5rem); } }
|
||||||
|
@media (prefers-reduced-motion: no-preference) { .wave-row { animation-delay: .85s; } }
|
||||||
|
.topnav .brand-rest { white-space: pre; } /* в flex-контейнере ведущий пробел иначе схлопывается */
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Фикс: Аман Ибраш">
|
<meta property="og:title" content="Фикс: Аман Ибраш">
|
||||||
<meta property="og:description" content="Тут будет про фикс: байк, маршруты и километры.">
|
<meta property="og:description" content="Тут будет про фикс: байк, маршруты и километры.">
|
||||||
<meta property="og:image" content="https://example.com/og/home.png">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|||||||
+8
-3
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Аман Ибраш: давай общаться">
|
<meta property="og:title" content="Аман Ибраш: давай общаться">
|
||||||
<meta property="og:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
|
<meta property="og:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
|
||||||
<meta property="og:image" content="https://example.com/og/home.png">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Аман Ибраш: давай общаться">
|
<meta name="twitter:title" content="Аман Ибраш: давай общаться">
|
||||||
<meta name="twitter:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
|
<meta name="twitter:description" content="Тренировки, работа и фикс. Пиши, пообщаемся.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/home.png">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/home.png">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
@@ -43,12 +43,17 @@
|
|||||||
<h2 id="h-talk">Давай общаться</h2>
|
<h2 id="h-talk">Давай общаться</h2>
|
||||||
<p class="cta" id="cta">Хочешь пообщаться? Пиши, отвечу в течение пары дней.</p>
|
<p class="cta" id="cta">Хочешь пообщаться? Пиши, отвечу в течение пары дней.</p>
|
||||||
<ul class="links" id="links">
|
<ul class="links" id="links">
|
||||||
<li><a class="link link--mail" id="mail" href="mailto:hello@example.com"><span class="ic" aria-hidden="true">@</span><span class="lk">Почта</span><span class="lh">hello@example.com</span></a></li>
|
<li><a class="link link--mail" id="mail" href="mailto:amnibrash@gmail.com"><span class="ic" aria-hidden="true">@</span><span class="lk">Почта</span><span class="lh">amnibrash@gmail.com</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="wave-row" id="wave-row">
|
||||||
|
<button class="wave-btn" id="wave-btn" type="button"><span class="wave-em" aria-hidden="true">👋</span> Помахать</button>
|
||||||
|
<p class="wave-count" id="wave-count" aria-live="polite">Уже помахали: <b id="wave-n">…</b></p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/js/nav.js" defer></script>
|
<script src="/js/nav.js" defer></script>
|
||||||
<script src="/js/card.js" defer></script>
|
<script src="/js/card.js" defer></script>
|
||||||
|
<script src="/js/wave.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Тихая кнопка «Помахать»: общий счётчик для всех посетителей хранится на сервере (/api/waves, /api/wave).
|
||||||
|
// Если API недоступен (например, сайт открыт без бэкенда), блок просто прячется, остальная страница не страдает.
|
||||||
|
(function () {
|
||||||
|
var row = document.getElementById('wave-row');
|
||||||
|
var btn = document.getElementById('wave-btn');
|
||||||
|
var num = document.getElementById('wave-n');
|
||||||
|
var line = document.getElementById('wave-count');
|
||||||
|
if (!row || !btn || !num) return;
|
||||||
|
|
||||||
|
var count = null, busy = false, msgTimer = null;
|
||||||
|
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
var fmt = function (n) { return n.toLocaleString('ru-RU'); };
|
||||||
|
|
||||||
|
// число плавно «докручивается» до нового значения
|
||||||
|
function setCount(n) {
|
||||||
|
var from = count === null ? n : count; count = n;
|
||||||
|
if (reduce || from === n) { num.textContent = fmt(n); return; }
|
||||||
|
var t0 = performance.now();
|
||||||
|
(function step(now) {
|
||||||
|
var k = Math.min(1, (now - t0) / 350);
|
||||||
|
num.textContent = fmt(Math.round(from + (n - from) * k));
|
||||||
|
if (k < 1) requestAnimationFrame(step);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
|
function say(text) { // короткое сообщение вместо счётчика
|
||||||
|
clearTimeout(msgTimer);
|
||||||
|
line.textContent = text;
|
||||||
|
msgTimer = setTimeout(function () { line.textContent = 'Уже помахали: '; line.appendChild(num); }, 2200);
|
||||||
|
}
|
||||||
|
function plusOne() { // маленькое «+1» уплывает вверх
|
||||||
|
if (reduce) return;
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.className = 'wave-plus'; s.textContent = '+1'; s.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.appendChild(s);
|
||||||
|
s.addEventListener('animationend', function () { s.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/waves').then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (d) { setCount(d.count); })
|
||||||
|
.catch(function () { row.classList.add('off'); });
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (busy || count === null) return;
|
||||||
|
busy = true; btn.disabled = true;
|
||||||
|
var prev = count;
|
||||||
|
setCount(count + 1);
|
||||||
|
btn.classList.remove('waving'); void btn.offsetWidth; btn.classList.add('waving');
|
||||||
|
plusOne();
|
||||||
|
fetch('/api/wave', { method: 'POST' }).then(function (r) {
|
||||||
|
if (r.status === 429) { say('Не так быстро, раз в секунду'); throw 0; }
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
return r.json();
|
||||||
|
}).then(function (d) { setCount(d.count); })
|
||||||
|
.catch(function (e) { count = prev; num.textContent = fmt(prev); if (e !== 0) say('Не засчиталось, попробуй ещё раз'); })
|
||||||
|
.then(function () { setTimeout(function () { busy = false; btn.disabled = false; }, 1000); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Aman's World: Аман Ибраш">
|
<meta property="og:title" content="Aman's World: Аман Ибраш">
|
||||||
<meta property="og:description" content="Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
|
<meta property="og:description" content="Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
|
||||||
<meta property="og:image" content="https://example.com/og/projects-amans-world.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects-amans-world.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Aman's World: Аман Ибраш">
|
<meta name="twitter:title" content="Aman's World: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
|
<meta name="twitter:description" content="Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/projects-amans-world.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects-amans-world.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Хоумлаб: Аман Ибраш">
|
<meta property="og:title" content="Хоумлаб: Аман Ибраш">
|
||||||
<meta property="og:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
|
<meta property="og:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
|
||||||
<meta property="og:image" content="https://example.com/og/projects-homelab.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects-homelab.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Хоумлаб: Аман Ибраш">
|
<meta name="twitter:title" content="Хоумлаб: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
|
<meta name="twitter:description" content="Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/projects-homelab.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects-homelab.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Проекты: Аман Ибраш">
|
<meta property="og:title" content="Проекты: Аман Ибраш">
|
||||||
<meta property="og:description" content="Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
|
<meta property="og:description" content="Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
|
||||||
<meta property="og:image" content="https://example.com/og/projects.jpg">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/projects.jpg">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Проекты: Аман Ибраш">
|
<meta name="twitter:title" content="Проекты: Аман Ибраш">
|
||||||
<meta name="twitter:description" content="Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
|
<meta name="twitter:description" content="Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.">
|
||||||
<meta name="twitter:image" content="https://example.com/og/projects.jpg">
|
<meta name="twitter:image" content="https://me.ibrash.duckdns.org/og/projects.jpg">
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://example.com/sitemap.xml
|
Sitemap: https://me.ibrash.duckdns.org/sitemap.xml
|
||||||
|
|||||||
+8
-8
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url><loc>https://example.com/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/</loc></url>
|
||||||
<url><loc>https://example.com/arch/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/arch/</loc></url>
|
||||||
<url><loc>https://example.com/projects/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/projects/</loc></url>
|
||||||
<url><loc>https://example.com/arch/quiet-courtyard/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/arch/quiet-courtyard/</loc></url>
|
||||||
<url><loc>https://example.com/arch/exhibition-pavilion/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/arch/exhibition-pavilion/</loc></url>
|
||||||
<url><loc>https://example.com/arch/loft-complex/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/arch/loft-complex/</loc></url>
|
||||||
<url><loc>https://example.com/projects/amans-world/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/projects/amans-world/</loc></url>
|
||||||
<url><loc>https://example.com/projects/homelab/</loc></url>
|
<url><loc>https://me.ibrash.duckdns.org/projects/homelab/</loc></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Тренировки: Аман Ибраш">
|
<meta property="og:title" content="Тренировки: Аман Ибраш">
|
||||||
<meta property="og:description" content="Тут будет про тренировки: что делаю, как часто и во сколько встаю.">
|
<meta property="og:description" content="Тут будет про тренировки: что делаю, как часто и во сколько встаю.">
|
||||||
<meta property="og:image" content="https://example.com/og/home.png">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
<meta property="og:locale" content="ru_RU">
|
<meta property="og:locale" content="ru_RU">
|
||||||
<meta property="og:title" content="Работа: Аман Ибраш">
|
<meta property="og:title" content="Работа: Аман Ибраш">
|
||||||
<meta property="og:description" content="Тут будет про работу: чем занимаюсь и куда расту.">
|
<meta property="og:description" content="Тут будет про работу: чем занимаюсь и куда расту.">
|
||||||
<meta property="og:image" content="https://example.com/og/home.png">
|
<meta property="og:image" content="https://me.ibrash.duckdns.org/og/home.png">
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|||||||
Reference in New Issue
Block a user