Я сделал себе сайт на др...

  • #code
  • #it
  • #problem-solving

Решил я запилить себе сайтик на день рождения. В этом посте я немного расскажу о его создании, почему он сделан именно на Astro.

Итак, сайт (включая API) сделан на Astro, в качестве CMS используется directus, т.к. достаточно прост в использовании и не требует много ресурсов. Для пуш-уведомлений используется Firebase Cloud Messaging.

Всё это поднято в Docker Compose на виртуальной машине. Ну и естественно для защиты и кэширования используется Cloudflare.

Мой опыт с Remix, или почему именно Astro?

Изначально планировалось написать этот сайт на Remix. Зачем? SSR. Хотелось поэкспериментировать с SEO, к тому же чем меньше JS кода на клиенте, тем лучше.

Так вот Remix, как и NextJS - отличные решения, но они всё ещё слишком привязаны к реакту. И ещё одна большая проблема - в итоге весь Javascript бандл всё равно отправляется на клиент. А теперь добавим сюда мой способ перевода (см. [[#i18n]]). Да, в Remix можно отключить клиентский код, но тогда особого смысла от всей тесной интеграции с реактом нет. Ещё концепция loader и action, которая приводит к дублированию функционала в файлах API-эндпоинтов и страниц.

Ещё одна вещь, которая мне не нравится в Remix на данный момент - их переход на flat routes. С моей точки зрения, директории лучше всего подходят для иерархически организованных URL, а они почти всегда так и организованы. Да, есть плагин, который возвращает старую систему с дополнительными фичами, но я запутался в его документации.

Astro - фремйворконезависимый, с учётом слабой интерактивности моего сайта, реакт (или что-то другое) не нужен. Достаточно ванильного JS с несколькими библиотеками (напр. Photoswipe).

Плюсы Astro:

  • простота, он как раз отлично подходит, когда не нужно делать очередной интернет магазин
  • поддержка markdown в качестве файлов страниц. Полезно для сайтов с документации или статичных блогов. Я всё же решил использовать CMS, дабы ознакомиться с ними.
  • Как и у других подобных фреймфорков, frontend и backend можно делать в одном проекте
  • Можно писать на ванильном JS. Не тащите целый реакт, когда вам нужна одна интерактивная кнопочка
  • Автоматический бандлинг и фигнерпринтинг скриптов (если не установлен атрибут is:inline). Т.е. можно делать так:
<script>  
    import Panzoom from '@panzoom/panzoom';  
    const elem = document.getElementById('image')!;  
    const panzoom = Panzoom(elem, {  
  
    });  
    elem.parentElement!.addEventListener('wheel', panzoom.zoomWithWheel);  
</script>

У ремикса, правда, был полезный функционал для работы с meta тэгами и т.п.

Как обезопасить администраторские ресурсы

Можно, конечно, просто поставить сложные пароли и настроить fail2ban, этого должно быть достаточно. Но с другой стороны, не хотелось бы, чтобы ненужный трафик вообще шёл на сервер.

Одним из самых простых и безопасных вариантов - VPN или SSH туннель. Однако, есть ещё один вариант - клиентские сертификаты TLS. Cloudlfare на бесплатном плане дает настроить аутентификацию по таким сертификатам. С учетом того, что также можно настроить Authenticated Origin Pull (т.е. mTLS между конечным сервером и Cloudlflare), это позволяет добиться того, что левый трафик просто не пройдет дальше Cloudflare. И даже если кто-то подключиться к бэкенду напрямую по IP, его встретит ошибка из-за включенного mTLS. Ну и правила WAF можно и нужно добавить.

У такого подхода есть и ещё один приятный бонус - можно использовать заголовки для авторизации. Например, хранилище на files.captaindno.xyz. Когда клиентский сертификат предоставлен, оно открывается с доступом на запись, а когда нет - только для чтения и только в директории public (ну и share-ы тоже). Разумеется, важна осторожность при настройке кэширования, когда речь идёт о ресурсах с ограниченным доступом.

i18n

Так как поддерживается только два языка (русский и английский), заморачиваться с различными i18n библиотеками не хотелось. К тому же, как правило, интернационализация подразумевает вынос всего текста в отдельные файлы - в документ мы подставляем строки под ключу. Это не очень удобно. Поэтому, для перевода просто используются компоненты вот такого вида:

---  
const lang = Astro.params.lang!;  
---  
{lang === 'en' && <slot/>}

Затем они используются вот так:

<EN>Go to settings</EN>  
<RU>Перейти к настройкам</RU>

Да, такой подход умножает количество текста в файле в два раза, но это не так критично для такого небольшого сайта с одним человеком, который поддерживает код.

Удобно добавлять картинки в статьи

Вообще, изображения для блога не обязательно должны храниться в CMS, но так иногда удобнее. Но, как уже говорилось выше, админские ресурсы защищены mTLS, и CMS в том числе. К тому же она под другим субдоменом.

Дело в том, что некоторые изображения не показываются в галерее, но доступны. Некоторые вообще недоступны с основного сайта. Но когда пишешь статью, хочется видеть картиночку. Простым добавлением авторизации для запросов на картинки это не решить - тогда невозможно будет кэшировать изображения на CDN, что крайне печально. Кроме того, для photoswipe необходимы атрибуты width и height, id для получения подписи и т.п.. Вручную их прописывать неудобно. И про альтернативный текст не забываем.

Поэтому в directus был добавлен flow, который для каждой картинки автоматически генерирует поле такого вида:

<!--[img]image_uid-->
<img src="https://directus.captaindno.xyz/assets/image_uid"/>

Его можно просто скопировать куда угодно, если есть доступ к directus, оно будет грузиться при редактировании.

Затем, при рендеринге странички мы автоматически редактируем тэги изображений. Всё происходит прямо на сервере, как и рендеринг md в html.

for (let i = 0; i < lines.length - 1; i++) {  
    const line = lines[i];  
    const m = line.match(/<!--\[img\](.*)-->/);  
    if (m) {  
        const imageId = m[1]!;  
        const imgTag = parse(lines[i + 1]!)!.querySelector('img')!;  
        imgTag.setAttribute('src', `/content/images/${imageId}?variant=full`);  
        imgTag.setAttribute('data-id', imageId);  
  
        const imgDetails = await getImageDetailsWithLang(imageId, lang);  
  
        if (!imgDetails) continue;  
  
        imgTag.setAttribute('data-width', imgDetails.width);  
        imgTag.setAttribute('data-height', imgDetails.height);  
  
        // Add alt text  
        if (!imgTag.hasAttribute('alt')) imgTag.setAttribute('alt', imgDetails.description);  
  
        // Modify actual line  
        lines[i + 1] = imgTag.outerHTML;  
    }  
}

Заключение

В целом, этот сайт спокойно можно бы было сделать и без CMS, используя коллекции контента Astro. Но опыт работы с CMS всегда полезен, так что всё сделано не зря.

Кажется (но это не точно), вам нравится мой контент. Перейдите в раздел настроек, чтобы подписаться на пуш-уведомления или разрешить сбор аналитических данных, которые позволяют улучшать этот сайт и его содержимое.

Перейти к настройкам