# Yocto mirror

{% infobox %}
This article uses Literate Programming: it contains runnable source-code!
To extract the code from the MarkDown File, use the tool presented in the
[A new spin on literate programming](https://personalcompute.net/resources/literate-programming) article.
{% endinfobox %}

## Part I - Building the core of the application

### The ArchiveSet abstraction

```lruby
{"filename": "archiveset.rb"}
<<spdx_headers>>

class ArchiveSet
<<archiveset_constructor>>

<<archiveset_lookup>>

<<archiveset_cleanup>>
end
```

The project is built around the `ArchiveSet` class. When instantiated, this class
takes a list of archive files (usually `tar`, but other file-formats are accepted),
uses [`fuse-archive`](https://github.com/google/fuse-archive) to mount them to
unique temporary directories (created using [`Dir.mktmpdir`](https://docs.ruby-lang.org/en/3.3/Dir.html#method-c-mktmpdir)),
and stores the mapping between the original archive-file and the mount-point.

Also, if one of the `fuse-archive` mount operations throws an error (for
example, if one of the archives is corrupted or inaccessible), the error is
processed silently.

Since we're using a fuse mount, the archives are not expanded on the drive, and
the files stored inside the archives are made available only on demand.

```lruby
{"name": "archiveset_constructor"}
  def initialize(archive_list)
    @archive_mapping = Hash.new

    archive_list.each do |archive_filepath|
      newtempdir = Dir.mktmpdir
      begin
        return_code_ok = system "fuse-archive -o nocache,notrim,quiet #{archive_filepath} #{newtempdir}"
        @archive_mapping[newtempdir] = archive_filepath if return_code_ok
      rescue
        Dir.rmdir newtempdir
      end
    end
  end
```

We can check this process using an [`irb`](https://ruby.github.io/irb/) shell:

```lruby
irb(main):001> require './archiveset'
irb(main):002> inst = ArchiveSet.new(['/home/user/archive.tar'])
irb(main):003> inst.instance_variable_get :@archive_mapping
=> {"/tmp/d20260621-1889463-ckv11s" => "/home/user/archive.tar"}
```

Once the `ArchiveSet` is initialized, we can use a `lookup` method
that iterates the set of archives and check in every one of them
if a target-path is found. At the end it returns a dictionary
related to the first encountered file matching that target-path,
or a `not_found` element.

```lruby
{"name": "archiveset_lookup"}
  def lookup(searched_suffix)
    @archive_mapping.each do |path, source_archive|
      temp_filepath = "#{path}/#{searched_suffix}"

      if File.exist? temp_filepath
        return {
          status: :ok,
          filepath: temp_filepath,
          archive: source_archive
        }
      end
    end

    { status: :not_found }
  end
```

We can continue the check using `irb` shell:

```lruby
irb(main):004> inst.mirror_lookup('known_file')
=> {status: :ok, filepath: "/tmp/d20260621-1889463-ckv11s/known_file", archive: "/home/user/archive.tar"}
irb(main):005> inst.mirror_lookup('unknown_file')
=> {status: :not_found}
```

#### Configuring the ArchiveSets

For Yocto builds, we'll need two ArchiveSets: one for downloaded source-code
and the second for sstate.

Then, to allow a server administrator to define what goes into each ArchiveSet,
we set up a separate file (called `settings.rb`) containing a dictionary, where
every record corresponds to a list of archives.

The template is the following:

```lruby
{"filename": "settings.rb"}
<<spdx_headers>>

ARCHIVE_PATHS = {
  downloads: [],
  sstate: []
}
```

Then, in the main application file, we import the `archiveset.rb` and
`settings.rb` files into the current namespace and define the
`mount_archivesets` method. This method reads the `ARCHIVE_PATHS` dictionary
and, for every key of the dictionary, calls the `ArchiveSet` constructor.

```lruby
{"name": "require_settings_and_archiveset"}
require './archiveset'
require './settings'
```

```lruby
{"name": "mount_archivesets_definition"}
def mount_archivesets
  return_mapping = Hash.new

  ARCHIVE_PATHS.each do |archive_type, archive_list|
    return_mapping[archive_type] = ArchiveSet.new archive_list
  end

  return_mapping
end
```

### Building the mirror

One important part of the application is the `configure` block. It defines
a key-value data-structure that gets evaluated once, at the launch
of the web-application, and controls how the application runs.

```lruby
{"name": "configure_block"}
configure do
<<configure_block_database_setup>>
  set :mirror_paths, mount_archivesets
  set :port, 8123
  set :bind, '0.0.0.0'
end
```

The `bind` and `port` keys control the address that the web-server will listen
on. By default the `bind` address is `localhost`, so in order to make the
mirror accessible across the network we have to replace it with `0.0.0.0`,
meaning "listen on all available network interfaces", on port `8123`.

The `mirror_paths` initialization will call the `mount_archivesets` method once and
store the returned dictionary. The result of that method can be accessed from
various handlers by calling `settings.mirror_paths`.

The rest of the application relies on [route-handlers](https://sinatrarb.com/intro.html#routes)
to call the correct backend code whenever an URL is requested.

The heavy-lifting is done by a GET-handler for paths matching the template
`/mirror/:type/:project_id/:build_id/*`. The code inside the handler can find out
the string that matched the `:type` pattern by calling `params['type']`. The last
part of the pattern is a wildcard, which can be accessed by calling `params['splat']`
(this is represented as an array).

The two results of the handler are either:
* call [`send_file`](https://sinatrarb.com/intro.html#sending-files) and deliver the file
* call [`halt 404`](https://sinatrarb.com/intro.html#halting) 

The handler will be called when requesting URL addresses like:
* http://10.0.0.1:8123/mirror/downloads/raspberrypi_build/0001/linux.tar.gz
* http://10.0.0.1:8123/mirror/downloads/raspberrypi_build/19700101/linux.tar.gz
* http://10.0.0.1:8123/mirror/sstate/main_release/19700101/00/aa/00aafffff.tgz

```lruby
{"filename": "app.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>

<<require_sequel>>

<<cleanup_hook>>

require 'sinatra'

<<require_settings_and_archiveset>>

<<mount_archivesets_definition>>

<<make_database_connection>>

<<configure_block>>

get '/mirror/:type/:project_id/:build_id/*' do
  # The application supports only 2 types of mirrored objects: "sstate" and
  # "downloads". Convert the types to numbers, and return an error if the
  # client requests something else from the mirror.
  request_type = case params['type']
                 when 'downloads' then 0
                 when 'sstate' then 1
                 else halt 404
                 end

  suffix = params['splat'].join('/')

  lookup_result = settings.mirror_paths[params['type'].to_sym]
                          .lookup(suffix)

  if lookup_result[:status] == :ok
    # Log the access and send the file
<<log_cache_hit>>

    send_file lookup_result[:filepath]
  else
    # Log the error and return code 404
<<log_cache_miss>>

    halt 404
  end
end

<<index_backend>>
```

### Dependency management

We'll store the libraries in the `vendor/bundle` subdirectory
of the application. This is done to avoid changing the host system's
libraries.

```yaml
{"filename": ".bundle/config"}
---
BUNDLE_PATH: "vendor/bundle"
```

And then we add the libraries, one by one, in the `Gemfile`:

```lruby
{"filename": "Gemfile"}
<<spdx_headers>>

source 'https://rubygems.org'

# Web-application prerequisites
gem 'puma'
gem 'rackup'
gem 'sinatra'

# Database support
<<database_dependencies>>

<<rubocop_dependencies>>
```

## Part II - Adding a clean shutdown method

One shortcoming of the application is that when it terminates, it can
leave mounted directories. This can be a problem if the
archives are on external drives, and we want to disconnect
the drives, because we'll see the "target is busy" message.

### A cleanup method for the ArchiveSet

The first step to do a clean shutdown is to add a method in
the `ArchiveSet` class that unmounts the archives, and deletes
the temporary directories.

```lruby
{"name": "archiveset_cleanup"}
  def cleanup
    @archive_mapping.each_key do |mount_dir|
      mount_dir_string = mount_dir.to_s
      begin
        system "umount #{mount_dir_string}"
        Dir.rmdir mount_dir_string
      rescue
        puts "Error cleaning up #{mount_dir_string}."
      end
    end
  end
```

### A shutdown hook for Sinatra

Then, in the sinatra app, we can build a callback triggered on the application's shutdown.

Ruby allows the set-up of [`at_exit`](https://docs.ruby-lang.org/en/3.4/Kernel.html#method-i-at_exit)
callbacks, which will be executed right before the application shutdown.

The trick here is to add the hook before the `require 'sinatra'` line.

The code itself just calls the `cleanup` method for every `ArchiveSet` instance.

<https://stackoverflow.com/questions/11105556/where-do-i-put-code-in-sinatra-that-i-want-to-execute-when-the-app-is-shutdown>

```lruby
{"name": "cleanup_hook"}
at_exit do
  settings.mirror_paths.each_value(&:cleanup)
end
```

There's a potential for a race-condition in the above code (an application exit
triggered before the `configure` block has executed completely), but it's
not a big deal.

## Part III - Database and Analytics

### Adding a database

For this project we'll use the [`sequel`](https://sequel.jeremyevans.net/) library
with an `sqlite` backend, to store a single table containing the log of the event.
The `sequel` library has a handy [cheasheet](https://sequel.jeremyevans.net/rdoc/files/doc/cheat_sheet_rdoc.html)
containing the most frequent use-cases.

This information will be useful to get analytics about the builds, which is
the average cache utilisation and the cache-hit ratio.

To add the support, the first thing to do is to add the dependencies in the
`Gemfile`:

```lruby
{"name": "database_dependencies"}
gem 'sequel'
gem 'sqlite3'
```

Then, we need to add the proper `require` line at the beginning of the application
source-file.

```lruby
{"name": "require_sequel"}
require 'sequel'
```

Another thing to do is building a connection-instance, where we specify the
database's backend (`sqlite`) and the file used for persistence (`mirror.db`
of the current directory). If the `mirror.db` file doesn't exist, it will
be created, and showing up as a database with no tables.

Since we want to keep the project simple, we won't be using migrations. Instead,
we're defining the table (called `:requests`) inside an `db.create_table?` block

The `.create_table?` block will be mapped to a `CREATE TABLE IF NOT EXISTS`
statement, so if the database was already initialized, this block does nothing.

At the end, we return a handle to the database client.

```lruby
{"name": "make_database_connection"}
def make_database_connection
  db = Sequel.connect('sqlite://mirror.db')

  db.create_table? :requests do
    primary_key :id, null: false
    String :path, null: false
    String :project_id, null: false
    String :build_id, null: false
    Integer :request_type, null: false
    String :source_archive, null: true
    DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
  end

  db
end
```

Then inside the `configure` block we add a field that calls the
`make_database_connection` method at the start of the web application.
This way, from any request-handler we'll be able to access the table
by accessing `settings.sequel_conn`.

```lruby
{"name": "configure_block_database_setup"}
  set :sequel_conn, make_database_connection
```

And now we're ready to add records into the table, whenever a cache-hit
or cache-miss has been encountered.

```lruby
{"name": "log_cache_hit"}
    settings.sequel_conn[:requests]
            .insert(
              path: suffix,
              project_id: params['project_id'],
              build_id: params['build_id'],
              request_type: request_type,
              source_archive: lookup_result[:archive]
            )
```

As a convention, a record with `source_archive = NULL` represents
a cache-miss.

```lruby
{"name": "log_cache_miss"}
    settings.sequel_conn[:requests]
            .insert(
              path: suffix,
              project_id: params['project_id'],
              build_id: params['build_id'],
              request_type: request_type
            )
```

### Adding a static front page

Now that we have informations about the past requests, we can
show some analytics.

We use the [`erb`](https://sinatrarb.com/intro.html#views-templates) action to render
the template specified in the file `views/main.erb`.

```lruby
{"name": "index_backend"}
get '/' do
<<recent_requests_backend>>

<<cache_miss_backend>>

  erb(
    :main,
<<pass_local_vars>>
  )
end
```

The frontend:

```erb
{"filename": "views/main.erb"}
<%#
<<spdx_headers>>
%>
<h1>Mirror for Yocto builds</h1>

<h2>Set up your builds</h2>

<h3>Source-code archives</h3>

<pre>
SOURCE_MIRROR_URL = <%= request.scheme %>://<%= request.host %>:<%= request.port %>/mirror/downloads/my_raspberrypi_project/${DATETIME}"
INHERIT += "own-mirrors"
</pre>

<h3>SSTATE mirror</h3>

<pre>
SSTATE_MIRRORS = "file://.* <%= request.scheme %>://<%= request.host %>:<%= request.port %>/mirror/sstate/my_raspberrypi_project/${DATETIME}/PATH"
</pre>

<<recent_requests_frontend>>

<<recent_builds_frontend>>
```

### Adding the recent requests to the front page

To allow users to check if the mirror is working, we'll add on the front-page
the log of the last 20 requests handled by the mirror.

In the handler, we use the `sequel` API to extract the data.

```lruby
{"name": "recent_requests_backend"}
  recent_requests = settings.sequel_conn[:requests]
                            .order(Sequel.desc(:created_at))
                            .limit(20)
```

Then, inside the `erb` call, we map the `recent_requests` variable
from the local context to the `recent_requests` variable inside
the `main.erb` file.

```lruby
{"name": "pass_local_vars"}
    locals: {
      recent_requests: recent_requests,
<<pass_cache_builds>>
    }
```

Now, inside the `views/main.erb` file we add a new section
and call another sub-template at `views/render_table.erb`.
For this, we map the `recent_requests` variable from the local
context to the `table` variable from the `views/render_table.erb`
sub-template.

```erb
{"name": "recent_requests_frontend"}
<h2>Recent requests</h2>
<%= erb :render_table, locals: {table: recent_requests} %>
```

The generic template for tables (`views/render_table.erb`)
renders into HTML sequel dataframes, and we can reuse later.

```erb
{"filename": "views/render_table.erb"}
<%#
<<spdx_headers>>
%>
<table class="border-2 border-black">
  <tr>
    <% table.columns.each do |column_name| %>
      <th><%= column_name %></th>
    <% end %>
  </tr>
  <% table.each do |row| %>
    <tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
      <% row.each do |cell| %> 
        <td class="p-2"><%= Rack::Utils.escape_html(cell[1]) %></td>
      <% end %>
    </tr>
  <% end %>
</table>
```

### Adding analytics on recent builds

Another interesting thing to add to the front page is to
show the 10 previous builds, and show the cache-hits and
cache-misses for both source-code archives and sstate.

For queries containing non-trivial logic, it is easier to write
SQL code directly, rather than use the `sequel` abstraction layer.

```lruby
{"name": "cache_miss_backend"}
  cache_hit_ratios = settings.sequel_conn
                             .fetch(
                               <<-HEREDOC
                               SELECT
                                 project_id,
                                 build_id,
                                 MAX(created_at) last_request,
                                 SUM(CASE WHEN request_type = 0 AND source_archive IS NULL THEN 1 ELSE 0 END) download_misses,
                                 SUM(CASE WHEN request_type = 0 AND source_archive IS NOT NULL THEN 1 ELSE 0 END) download_hits,
                                 SUM(CASE WHEN request_type = 1 AND source_archive IS NULL THEN 1 ELSE 0 END) sstate_misses,
                                 SUM(CASE WHEN request_type = 1 AND source_archive IS NOT NULL THEN 1 ELSE 0 END) sstate_hits
                               FROM
                                 requests
                               GROUP BY
                                 project_id, build_id
                               ORDER BY
                                 last_request DESC
                               LIMIT 10
                               HEREDOC
                             )
```

This result-set needs passing to the `views/main.erb` view.

```lruby
{"name": "pass_cache_builds"}
      cache_hit_ratios: cache_hit_ratios
```

And in the `views/main.erb` view we reuse the `views/render_table.erb` sub-template
to render this result set too.

```erb
{"name": "recent_builds_frontend"}
<h2>Stats for builds</h2>
<%= erb :render_table, locals: {table: cache_hit_ratios} %>
```

## Part IV - Administrator's guide

### Prerequisites

The server needs the [`fuse-archive`](https://github.com/google/fuse-archive)
package in order to mount the archives, and the [`literate_tool.rb`](https://personalcompute.net/resources/literate-programming)
script in order to unpack the source-code.

### Installation and setup

```bash
# Download the literate-programming file
wget https://personalcompute.net/assets/literate-programming/yocto-download-mirror.md
# Unpack it
rbenv exec literate_tool.rb yocto-download-mirror.md
# Install dependencies
bundle install
```

Edit the `settings.rb` to specify the archives you want to expose in your mirror.

```lruby
{"filename": "settings.rb.example"}
<<spdx_headers>>

ARCHIVE_PATHS = {
  downloads: ['/var/archive/wrynose_downloads.tar', '/var/archive/whinlatter_downloads.tar'],
  sstate: ['/var/archive/wrynose_sstate.tar', '/var/archive/whinlatter_sstate.tar']
}
```

### Creating the downloads-archives

If you need to create the downloads-archives, just add the following
settings to your yocto configuration. You need to reference two
empty directories. After the mirrors are set up, you won't need them
anymore.

```python
DL_DIR = "/var/archive/new_mirror_downloads"
GITDIR = "/var/archive/new_git_dir"
BB_GENERATE_MIRROR_TARBALLS = "1"
```

Then, launch a build using the `--runall=fetch` option.

```bash
# If you're using kas, run:
kas build config.yml -- --runall=fetch

# If you're using direct bitbake commands, run:
bitbake core-image-minimal --runall=fetch
```

If you have more build-configurations, run the builds for all
of them, while reusing the same `DL_DIR`, `GITDIR` and
`BB_GENERATE_MIRROR_TARBALLS` values.

You need to clean-up the `/var/archive/new_mirror_downloads` a bit before archiving:

```bash
find /var/archive/new_mirror_downloads -name "*.done" -exec rm -f {} \;
```

Now you can create the archive:

```bash
cd /var/archive/new_mirror_downloads
tar --create --file=/var/archive/download_mirror.tar .
```

### Launching the application

```bash
# Run it
bundle exec ruby app.rb
```

## Part V - User's guide

### Setting up the Yocto build

```python
# Source-code archives
SOURCE_MIRROR_URL = "http://localhost:8123/mirror/downloads/my_raspberrypi_project/${DATETIME}"
INHERIT += "own-mirrors"

# Sstate objects
SSTATE_MIRRORS = "file://.* http://localhost:8123/mirror/sstate/my_raspberrypi_project/${DATETIME}/PATH"

BB_HASHSERVE_UPSTREAM = "hashserv.yoctoproject.org:8686"
```


## Part VI - Security considerations

Evven if the application is simple, we should spend some time discussing
what security practices are applied here.

### Preventing SQL Injection Attacks

Since we're using the `sequel` library to handle database access, preventing
SQL injection atacks is simple: as long as we don't manually build SQL
queries using string-concatenation, and instead use key-value pairs, we're
safe. The `sequel` library handles the prepared-statements for us.

### Preventing HTML Injection Attacks

On the dashbboard page, we display the file-path requested by the user.
To make sure that the requested file-path doesn't interfere with the
page, we call the [`Rack::Utils.escape_html`](https://rubydoc.info/gems/rack/Rack/Utils#escape_html-instance_method)
method on all displayed user-provided data, to sanitize it.

### Preventing path-traversal attacks

The Sinatra framework comes with path-traversal prevention out of the box,
implemented in the [`Rack::Protection`](https://sinatrarb.com/intro.html#configuring-attack-protection)
middleware.


### What's not handled - Privacy

The front page of the mirror will show the recent activity:

* which Yocto-projects were built recently,
* which mirrored objects were included in the current yocto-build,
* what's the schedule of the build system.

Also, since the application doesn't implement any authentication,
it means that any user can download any object stored inside the mirror,
whether we are talking about source-code, debug symbols or binary
artifacts.

For internal trusted networks and/or open-source projects it can be fine,
but it might clash with data-minimization & privacy objectives (in which
case you can simply delete or comment out the analytics part of the
project).

All commercial projects should be mirrored only secure networks
or VPNs anyway. Please consult your IT Security & Compliance experts.

### What's not handled - Detalied analytics, database concerns

This objective is in direct contradiction with the previous one:
one attempts to minimize the collected data, while the other one attempts
to collect and analyze more data.

One important thing we could track is the IP address of the client:
it could be the internal IP address of the build-machine (so it
doesn't identify an user, and it's useful to understand the server's status)
or a residential IP address of a volunteer (which might fall into the
Personally-Identifiable Information category of the privacy law).

Also, the database backend is `sqlite` which is fine for hundreds of concurrent builds,
but if we want to scale further (for massive commercial deployments) the entire
system should be migrated to `postgres` or a similar database. Fortunately the
`sequel` abstraction layer makes such a migration relatively painless.

Another scalability concern is that the table was defined without indexes,
so queries will slow down as the volume of the data inside of the database
grows.

### What's not handled - Zero-downtime changes

The focus of this application is simplicity, so changing the `ArchiveSet`
lists is done by editing a configuration-file and restarting the entire server.

There is no live-update capability nor "admin interface", since that would mean
implementing "privileged users".

### What's not handled - Reverse proxy, DoS protection

On untrusted networks, sysadmins typically add a reverse-proxy (also called
a frontend cache): typically an `nginx` server that deals with HTTPS (handles
the connection encryption), adds a layer of caching (responding to
frequently-requested objects without forwarding the request to the upstream
server) and security (by terminating malformed requests, block problematic
clients).

To fully support an frontend-cache, we'd have to do a couple of changes:

* In order to prevent remote users accessing directly the server, we need to
update the config-block to `bind` only to `localhost` or a specific LAN
address. Only the reverse-proxy server should be able to connect to the
Sinatra app.

* In the `views/main.erb` page, we use the request's metadata (as seen by the
sinatra server) to indicate how to set up the Yocto build to use the mirror.
Since it points to the Sinatra app (and not to the reverse-proxy), it will need to
change, and use the `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-Port`
headers.

* Set large cache-control values inside the response.

## Annexes

This section includes development notes, compliance documents and
boilerplate files.

### Annex - Rubocop linting

[RuboCop](https://rubocop.org/) is a Ruby linter: it flags strange or awkward snippets of source-code, and sometimes it even can correct them.

To add RuboCop to a project, we first add it as dependency on a Gemfile, with a separate gem-group (called `development`). This makes RuboCop not a "strong" dependency (always installed in the workspace) but an optional one (ignored by default, available on demand).

```lruby
{"name": "rubocop_dependencies"}
# Rubocop linting
group :development, optional: true do
  gem 'rubocop'
  gem 'rubocop-performance'
  gem 'rubocop-sequel'
end
```

To install it, we must first "unlock" the `development` group of dependencies, install it, and then run it.

```bash
# Unlock the "development" group of dependencies
bundle config set with 'development'
# Install the dependencies
bundle install
# Look for formatting issues
bundle exec rubocop
# Auto-fix formatting issues
bundle exec rubocop -A
```

Although the enforced [coding style](https://rubystyle.guide/) is quite good, some rules are not fitting the project, and we can disable them. Every project can configure which rules are applied, by configuring the tool.

```yaml
{"filename": ".rubocop.yml"}
AllCops:
  NewCops: enable

plugins:
  - rubocop-sequel
  - rubocop-performance

# Having complex code is not a problem. Sometimes we solve complex problems.
Metrics/BlockLength:
  Enabled: false
Metrics/MethodLength:
  Enabled: false

# Don't add extra noise
Style/Documentation:
  Enabled: false
Style/RescueStandardError:
  Enabled: false

# Calling the Hash.new constructor is ok.
Style/EmptyLiteral:
  Enabled: false

# Ignore frozen-string warnings.
Style/FrozenStringLiteralComment:
  Enabled: false
Style/MutableConstant:
  Enabled: false

# This project's Gemfile lists dependencies in the logical order.
# Ordering them alphabetically would make no sense.
Bundler/OrderedGems:
  Enabled: false

# Tech limitation.
Lint/ScriptPermission:
  Enabled: false
```

### Annex - The license

This project attepts to be mostly-compliant with the [REUSE Software](https://reuse.software/) specifications.

One requirement of the specification is to have the text of the chosen license (**MIT** in our case) in a file in the `LICENSES` directory. As such, we add it in:

```
{"filename": "LICENSES/MIT.txt"}
MIT License

Copyright (c) <year> <copyright holders>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
```

Another prerequisite of the specification is that every copyrightable file must include
two comments using a special formatting, containing the license and the author/copyright holder.

```lruby
{"name": "spdx_headers"}
# SPDX-FileCopyrightText: 2026 PersonalCompute.Net <publisher@PersonalCompute.Net>
# SPDX-License-Identifier: MIT
```

We can check the REUSE compliance using the lint tool:

```bash
pipx run reuse lint
```

### Annex - General page layout

```erb
{"filename": "views/layout.erb"}
<%#
<<spdx_headers>>
%>
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="/css/output.css" rel="stylesheet">
  </head>
  <body>
    <main class="container mx-auto mt-28 mb-32">
      <%= yield %>
    </main>
  </body>
</html>
```

### Annex - Updating the CSS

Since the stylesheet doesn't change that often, the minified CSS
is stored inline.

To regenerate it, download the standalong tool form
<https://github.com/tailwindlabs/tailwindcss/releases>
and run it in the main directory.

```bash
chmod +x ~/Downloads/tailwindcss-linux-x64                                            
~/Downloads/tailwindcss-linux-x64 -i ./public/css/input.css -o ./public/css/output.css --minify
```

```css
{"filename": "public/css/input.css"}
@import "tailwindcss";
```

```css
{"filename": "public/css/output.css"}
/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-sky-700:oklch(50% .134 242.749);--color-gray-100:oklch(96.7% .003 264.542);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-bold:700;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.static\!{position:static!important}.start-1{inset-inline-start:calc(var(--spacing) * 1)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-28{margin-top:calc(var(--spacing) * 28)}.mb-32{margin-bottom:calc(var(--spacing) * 32)}.\!block{display:block!important}.block{display:block}.contents{display:contents}.hidden{display:none}.inline{display:inline}.table{display:table}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-black{border-color:var(--color-black)}.p-2{padding:calc(var(--spacing) * 2)}.p-10{padding:calc(var(--spacing) * 10)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[rdoc-ref\:BigDecimal\@Not\+a\+Number\]{rdoc-ref:BigDecimal@Not+a+Number}.even\:bg-gray-100:nth-child(2n){background-color:var(--color-gray-100)}@media (hover:hover){.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:text-white:hover{color:var(--color-white)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}student@raspberrypi:~/dlmir $ 
```