I made myself a website for birthday...

  • #code
  • #it
  • #problem-solving

Well, I decided to build myself a website for birthday. In this post I'll explain how it was made and why I chose Astro.

So, this website, including API, uses Astro in combination with Directus as backend. Directus is easy to setup and use, while not being too hungry for CPU and RAM. Firebase is used for push-notifications.

Docker Compose is used for deployment. And of course, Cloudflare is used for protections and their CDN.

My experience with Remix

Actually, I planned to write everything here with Remix first. Why? SSR. I wanted to experiment with some SEO staff and also ship as little JS to end user as possible.

And Remix (as well as NextJS) are great frameworks, however they have some serious disadvantages for my use case.

Firstly, they depend on React. Don't get me wrong, I use React too, but as I said before - the end goal was to minimize bundle size as much as possible. And with my approach to [[#i18n]] (all languages are bundled together and fixing this is not a trivial task)...

Of course, there is a way to disable client JS in Remix, but then there is no reason for using Remix, I think. Also I don't like loaders and actions in page files, API should be separate and well documented, to enable other apps using it.

Additionally, I don't understand why Remix switched to flat routes. For me, hierarchical directory structure seems to be the best way to manage... well, hierarchical URL structure. Yes, I know that there is a plugin that enables old routing system, but its documentation was somewhat confusing.

And Astro does not depend on any particular framework. I can just write plain JS to add a little bit of interactivity.

Pros of Astro:

  • simplicity. NextJS or Remix are great for writing something like e-shop, while astro shines in personal blogs and similar stuff
  • native markdown support for pages. I used CMS and dynamic rendering, but for static documentation or blog you can easily write everything in MD
  • frontend and backend in one place (same as Remix and Next)
  • you can write in vanilla JS.
  • automatical bundling and fingerptinting for scripts inside html (except those that have attribute is:inline). So you can do this:
<script>  
    import Panzoom from '@panzoom/panzoom';  
    const elem = document.getElementById('image')!;  
    const panzoom = Panzoom(elem, {  
  
    });  
    elem.parentElement!.addEventListener('wheel', panzoom.zoomWithWheel);  
</script>

Remix had very nive features for working with headers and meta tags though.

Different approaches to admin resources securuty

The simplest way is to just set strong password, maybe hide behind some long base path, configure fail2ban etc... But I wanted to eliminate unneded traffic to my VPS. And as we all know, CMSs attract all kinds of automated attacks like light attracts moths.

So, what can we do? Easy way to block all unneded requests is to setup VPN or SSH tunnel and listen on localhost. However, I decided to use client TLS cerificates. Cloudlfare includes support for them even on free plan. Additionally, they have this thing called "Authenticated Origin Pull", which is mTLS between cloudlfare and your server. Basically, this means that we can set HTTP header on cloudlfare that indicates if user is authenticated with client cert. On backend, we can trust this header, because enabled mTLS guarantees that traffic to our server really came from Cloudflare, so client authentication is enforced. And of course, we can setup WAF rules to block traffic.

Additionally - service at files.captaindno.xyz automatically authorizes user thanks to client certificate authentication. When cert is present, writing to storage is permitted. Otherwise, you are restricted to public directory and can only view and download.

Of course, caution is required when configuring caching, when authentication is involved.

i18n

As only two languages are supported (Russian and English), I decided to avoid complex i18n libraries. Also, proper internationalization usually means moving actual text from HTML files to JSON/XML/database/other and using keys instead to render pages. This makes editing harder.

So I implemented components that look like this:

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

And use them in pages when needed:

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

Yes, the amount of text in file increases proportionally to number of supported languages. But I have only two, and I'm the only developer and translator.

Images in articles

Well, not all images in blogposts must come from CMS, but sometimes they do. And as I said earlier, direct access to CMS is protected with mTLS.

And some images are unlisted in gallery. Some are just not published, so no access at all. And CMS is on different subdomain from website. But it is useful to see images when you are editing an article. Also photoswipe needs image height and width, id is needed to load captions, alt text for accessibility. Adding all this manually and changing image url is not efficient at all.

So I added a flow to the CMS, that generates field that looks like this for all images:

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

It can be copied anywhere, and if directus is accessible, the image will be displayed.

And when article body is rendered, we just automatically change URL and add all missing attributes:

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;  
    }  
}

Conclusion

Actually, similar website can be created using content collections in Astro. And that would be a lot easier. But experience with CMS is useful anyway.

Looks like (just maybe) you are enjoying my content. Check out settings to subscribe to push-notifications or allow collections of some analytical data that helps to improve this website and its content.

Go to settings