<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://eggrain.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://eggrain.github.io/" rel="alternate" type="text/html" /><updated>2026-07-04T19:53:23+00:00</updated><id>https://eggrain.github.io/feed.xml</id><title type="html">Zoe Rain</title><subtitle>Hello, welcome to my blog! I hope you find the information here helpful, but these posts should not be taken as 100% correct ways to do anything.</subtitle><entry><title type="html">Orphan storage account</title><link href="https://eggrain.github.io/orphan-storage-account" rel="alternate" type="text/html" title="Orphan storage account" /><published>2026-06-16T01:35:00+00:00</published><updated>2026-06-16T01:35:00+00:00</updated><id>https://eggrain.github.io/legacy-blob-storage-account</id><content type="html" xml:base="https://eggrain.github.io/orphan-storage-account"><![CDATA[<p>Following the documentation about migrating legacy storage accounts referenced in my last post, I used the Azure Resource Graph with the query provided in that documentation to identify one relevant resource, a storage account that was not appearing in my All Resources list.</p>

<p>Looking at the details of it, I couldn’t see how it was being used in any way. I considered it may just be an orphan resource that was created at some point and now wasn’t being used for anything or needed. I went ahead with deleting it, and was warned the dependent resources included 318 bytes of “Tables” data, however the resource details also had some text like “this resource has no tables yet” so I went ahead and deleted it.</p>

<p><img src="assets\screenshots\orphan-storage-account.png" alt="Modal shown when deleting the storage account" /></p>

<p>Unless this somehow has some bad side effect later, this issue Microsoft emailed me about was not much of an issue for me as I was only marginally affected. The copying over of data to a new smaller disk that has been on my mind for a while is also a completely separate issue.</p>]]></content><author><name></name></author><category term="cloud" /><summary type="html"><![CDATA[Following the documentation about migrating legacy storage accounts referenced in my last post, I used the Azure Resource Graph with the query provided in that documentation to identify one relevant resource, a storage account that was not appearing in my All Resources list.]]></summary></entry><entry><title type="html">Technical debt has caught up to me again (storage)</title><link href="https://eggrain.github.io/technicaldebtonooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo" rel="alternate" type="text/html" title="Technical debt has caught up to me again (storage)" /><published>2026-06-13T01:35:00+00:00</published><updated>2026-06-13T01:35:00+00:00</updated><id>https://eggrain.github.io/storage-post</id><content type="html" xml:base="https://eggrain.github.io/technicaldebtonooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo"><![CDATA[<p>I got an email from Microsoft recently that I just loved, I just loved so much 😂 (not really but I pinned it): “Retirement notice: Migrate your legacy Azure general-purpose v1 account to a general-purpose v2 account” which links <a href="https://learn.microsoft.com/en-us/azure/storage/common/legacy-blob-storage-account-migration-overview">Overview of legacy blob storage account retirement</a>. Microsoft, you should title this something less general. Since I will be at the Azure Portal anyway, while I am there, I will try to tackle reducing my storage bill by changing my virtual hard disk too.</p>

<blockquote>
  <p>Your decision not to migrate an existing legacy blob storage account will be construed as consent for Microsoft to migrate the account on your behalf. - Microsoft</p>
</blockquote>

<p>Philosophically, if I do not do something by a certain time, is that necessarily a decision? What if I had a lot going on in my life and my technical skills were too weak? (Putting my web server on the cloud VM reminds me of when I took student loans.) But I have to admit Microsoft has summarized what to do for this in that linked-to documentation nicely. I’m not sure how much migrating a legacy storage account is coupled to specific resources like the hard disk I want to retire (it may be possible to do this with no change to it) but since this involves doing inventory of your storage resources and the new storage accounts have new pricing calculators, and I want to tackle the problem of copying data from this hard disk to a smaller one to be able to detach the 64 GB one anyway (this was a problem I said I would tackle in one of my previous blog posts… about 5 months ago 😇). The deadline is October 13, 2026, so I will take my time this week and try to learn something, too.</p>]]></content><author><name></name></author><category term="cloud" /><summary type="html"><![CDATA[I got an email from Microsoft recently that I just loved, I just loved so much 😂 (not really but I pinned it): “Retirement notice: Migrate your legacy Azure general-purpose v1 account to a general-purpose v2 account” which links Overview of legacy blob storage account retirement. Microsoft, you should title this something less general. Since I will be at the Azure Portal anyway, while I am there, I will try to tackle reducing my storage bill by changing my virtual hard disk too.]]></summary></entry><entry><title type="html">Resizing the VM thoughts</title><link href="https://eggrain.github.io/technicaldebtonoooo3" rel="alternate" type="text/html" title="Resizing the VM thoughts" /><published>2026-01-25T14:50:00+00:00</published><updated>2026-01-25T14:50:00+00:00</updated><id>https://eggrain.github.io/resizing-vm-was-successful</id><content type="html" xml:base="https://eggrain.github.io/technicaldebtonoooo3"><![CDATA[<p>I saw the 4 Gb VM had over 3 Gb free after the work of my last post and some additional work editing the paths of the disabled projects to project_name_DISABLED, ensuring processes wouldn’t start for them even if something tried to. Though I haven’t checked on the PostgreSQL cluster yet, it seemed like there should have been enough memory so I proceeded with resizing the VM.</p>

<p>After resizing, I saw an interesting change in the available memory bytes. There was an initial bumpy thing then a drastic, linear decrease for a while (40 minutes or so), after which available memory continued to decrease somewhat at a slower rate and overnight stabilized at around ~340 MiB free. I am not exactly sure about the graph where the drastic decrease happens. Linux boots and starts using free memory to cache disk reads. If the resize involves restarting the VM, the RAM of the 4 Gb machine is lost, and after boot it builds the cache memory starting from 0. Maybe what it’s showing is a sum of memory while the 4 Gb VM deallocates and the 1 Gb VM pops into existence. Chat disagrees with that explanation so take it with a grain of salt but to me it seems consistent with the graph. The later slower decrease and stabilization are surely the processes continuing to warm up.</p>

<p><img src="assets\azure-graph-resizing-vm.png" alt="Graph showing available memory bytes change due to resizing the VM, there's an initial drastic decrease after there has been a gentle decrease" /></p>

<p>My experience with these 2 VM sizes (B-series v2 “B2pts_v2” with the same vCPUs (2), data disks (4), and max read/write speed (3750 IOPS) but either 1GiB or 4GiB RAM–which I’ve been calling 1 or 4 Gb) was that when the only app on the machine was Anki Books (Ruby on Rails), it could just barely run on the 1 Gb RAM machine. The Ruby jobs which create Anki deck package files would cause it to fall over, especially the job to <code class="language-plaintext highlighter-rouge">find_each</code> iterate and make every Anki deck package file. But sometimes the VM was falling over at random times without me starting those jobs. So with Anki Books and its app server Passenger using memory ruled out now, this was more a test of if it could handle my 2 relatively simpler Razor Pages apps, and so far it looks good.</p>]]></content><author><name></name></author><category term="cloud" /><summary type="html"><![CDATA[I saw the 4 Gb VM had over 3 Gb free after the work of my last post and some additional work editing the paths of the disabled projects to project_name_DISABLED, ensuring processes wouldn’t start for them even if something tried to. Though I haven’t checked on the PostgreSQL cluster yet, it seemed like there should have been enough memory so I proceeded with resizing the VM.]]></summary></entry><entry><title type="html">Turning off 2 ASP.NET Core apps saved ~200-300 Mb of memory</title><link href="https://eggrain.github.io/technicaldebtonoooo2" rel="alternate" type="text/html" title="Turning off 2 ASP.NET Core apps saved ~200-300 Mb of memory" /><published>2026-01-21T23:35:00+00:00</published><updated>2026-01-21T23:35:00+00:00</updated><id>https://eggrain.github.io/initial-progress-on-saving-money</id><content type="html" xml:base="https://eggrain.github.io/technicaldebtonoooo2"><![CDATA[<p>I finally got something working so links to my subdomains didn’t go to the apps I am taking down today. Initially I tried to use DNS records, but with Namecheap to make the changes, and using Lets Encrypt digital certificates on the server, it wasn’t possible to make it work for HTTPS (makes sense). Instead I accomplished it in an even better way with Apache redirecting to a post on my blog, by editing the virtual host .conf files.</p>

<p>Afterwards, I turned off the 2 ASP.NET Core apps, and checked the Azure portal metrics to see the available memory bytes increasing. Now I’m seeing it later and it looks like 200-300 Mb of memory was returned to the VM. I am happy my guess of 100-300 Mb per app process from the previous post was reasonable. For the graph below, imagine moving the bottom one up and to the right and connecting it on the left of the upper one.</p>

<p><img src="assets\azure-graph-after-turning-off-2-dotnetapps.png" alt="Graph showing available memory bytes increasing from about 2.9 Gb to 3.1 Gb" /></p>]]></content><author><name></name></author><category term="cloud" /><summary type="html"><![CDATA[I finally got something working so links to my subdomains didn’t go to the apps I am taking down today. Initially I tried to use DNS records, but with Namecheap to make the changes, and using Lets Encrypt digital certificates on the server, it wasn’t possible to make it work for HTTPS (makes sense). Instead I accomplished it in an even better way with Apache redirecting to a post on my blog, by editing the virtual host .conf files.]]></summary></entry><entry><title type="html">Technical debt has caught up to me</title><link href="https://eggrain.github.io/technicaldebtonoooooooooooooooooooooooooooooooo" rel="alternate" type="text/html" title="Technical debt has caught up to me" /><published>2026-01-16T01:35:00+00:00</published><updated>2026-01-16T01:35:00+00:00</updated><id>https://eggrain.github.io/tech-debt</id><content type="html" xml:base="https://eggrain.github.io/technicaldebtonoooooooooooooooooooooooooooooooo"><![CDATA[<p>In 2024 I moved from Massachusetts to Georgia. Now in January 2026, I am trying to save money.</p>

<p>In anticipation of moving, before I moved to Georgia, I migrated one website–Anki Books–that was being hosted on an old college thinkpad running Ubuntu to an Azure Virtual Machine which was successful. I moved from Massachusetts to Georgia to Washington in one month and my VM was serving a couple always available websites with backends and databases.</p>

<p>Today I have a 4 Gb RAM Azure Virtual Machine and a 64 Gb managed disk with a few app SQLite databases, thousands of zipped Anki SQLite databases, a PostgreSQL cluster, and images on disk. The VM serves a couple React apps, four ASP.NET Core apps, and a Ruby on Rails app. React apps are just static assets, Ruby is horrible for memory and cannot be salvaged on my budget, and each of the four ASP.NET Core apps is its own <code class="language-plaintext highlighter-rouge">dotnet</code> process which might use 100-300 Mb of memory.</p>

<p>Not only that, but my main website in this setup is a portfolio website with links to all of these projects, which are all hosted as subdomains of the same domain name…</p>

<h1 id="my-plan">My plan</h1>

<p>First I will setup DNS records for my domain, specifically for the subdomains that prior were serving Anki Books (Rails), a weekend Razor Pages app <code class="language-plaintext highlighter-rouge">health</code>, and Larder (ASP.NET Core Web API). (1/21 edit: it turned out to be easier to keep the subdomains pointed to the VM and have Apache redirect requests to them.) After verifying no links are broken, I will shut down those websites, and probably edit the main website.</p>

<p>That will give me a much simpler architecture of just two React apps and two ASP.NET Core Razor Pages apps. With no production Anki Books, the data can be dumped into a tar file and the PostgreSQL cluster can be obliterated to save memory. With all that done, I would try resizing the VM to a 1 Gb RAM one.</p>

<p>If that can run on the smaller VM, great, and I could also look into attaching a smaller managed disk to the VM to save some money there.</p>

<p>If needed, it may be possible to refactor the architecture of the two remaining Razor Pages apps that are hopefully running on the 1 Gb machine into an architecture where they are libraries being used in one <code class="language-plaintext highlighter-rouge">dotnet</code> process but still hosting different apps at the subdomains. Having only two ASP.NET Core apps instead of 4 simplifies getting that refactor into production greatly.</p>

<p>As far as Anki Books, it may be possible to develop an ASP.NET Core app with EF Core scaffolding model classes around an SQLite database with the Ruby on Rails Anki Books data later but it would need to be after refactoring the other apps into the one ASP.NET Core host, and supposing it was possible to resize the VM to the cheaper one. If that is ultimately not successful, simply freezing everything would be my next plan.</p>]]></content><author><name></name></author><category term="cloud" /><summary type="html"><![CDATA[In 2024 I moved from Massachusetts to Georgia. Now in January 2026, I am trying to save money.]]></summary></entry><entry><title type="html">What I want to put into a template of a Razor Pages app</title><link href="https://eggrain.github.io/what-i-want-in-a-razor-pages-app-template" rel="alternate" type="text/html" title="What I want to put into a template of a Razor Pages app" /><published>2025-11-27T14:35:00+00:00</published><updated>2025-11-27T14:35:00+00:00</updated><id>https://eggrain.github.io/initial-work-on-health</id><content type="html" xml:base="https://eggrain.github.io/what-i-want-in-a-razor-pages-app-template"><![CDATA[<p>I’ve developed a few different apps with Razor Pages now. From my experiences, I’ve gravitated toward a specific monolithic architecture, set of dependencies, and continuous deployment approach that works for me as a solo developer working on hobby projects.</p>

<p>I start with <code class="language-plaintext highlighter-rouge">dotnet new razor -o appname</code>, remove the included frontend assets in <code class="language-plaintext highlighter-rouge">wwwroot</code>, add NPM, Bootstrap, Sass, a Sass <code class="language-plaintext highlighter-rouge">site.scss</code>, a <code class="language-plaintext highlighter-rouge">package.json</code> script to compile Bootstrap to <code class="language-plaintext highlighter-rouge">wwwroot</code>, ASP.NET Core Identity, Entity Framework Core (with SQLite),<code class="language-plaintext highlighter-rouge">dotnet-ef</code> to scaffold migrations, application classes derived from <code class="language-plaintext highlighter-rouge">DbContext</code>, <code class="language-plaintext highlighter-rouge">IdentityUser</code>, <code class="language-plaintext highlighter-rouge">PageModel</code>, <code class="language-plaintext highlighter-rouge">Program.cs</code> changes, a GitHub action to deploy the app to a VM, where it will run as a <code class="language-plaintext highlighter-rouge">systemd</code> service, served at a subdomain with Apache virtual hosts and secured with Let’s Encrypt.</p>

<p>This last weekend I started a new project, <code class="language-plaintext highlighter-rouge">health</code>, and deployed it with about one feature developed. This blog post describes the steps I followed to develop the minimal viable product and get it deployed. (While this initial work that I’ve done several times is fresh in my mind, I want to take some notes to document before I make this into an app template.)</p>

<h1 id="new-razor-pages-app-with-sass-compiled-bootstrap">New Razor Pages app with Sass-compiled Bootstrap</h1>

<p><code class="language-plaintext highlighter-rouge">dotnet new razor -o health</code> created the initial application, creating a new project called <code class="language-plaintext highlighter-rouge">health</code> in a folder <code class="language-plaintext highlighter-rouge">health</code>. Inside <code class="language-plaintext highlighter-rouge">wwwroot</code>, the <code class="language-plaintext highlighter-rouge">lib</code> folder which includes Bootstrap and jQuery was deleted.</p>

<p>To create <code class="language-plaintext highlighter-rouge">package.json</code> and add Sass and Bootstrap:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">npm init <span class="nt">-y</span>

npm <span class="nb">install </span>bootstrap @popperjs/core
npm <span class="nb">install</span> <span class="nt">--save-dev</span> Sass</code></pre></figure>

<p>Popper is needed for some Bootstrap components like tooltips. To customize the Boostrap theme, I added a <code class="language-plaintext highlighter-rouge">scss/site.scss</code> file:</p>

<figure class="highlight"><pre><code class="language-sass" data-lang="sass"><span class="nv">$primary</span><span class="p">:</span> <span class="nf">rgb</span><span class="p">(</span><span class="m">23</span><span class="o">,</span> <span class="m">209</span><span class="o">,</span> <span class="m">10</span><span class="p">)</span><span class="err">;</span>
<span class="nv">$secondary</span><span class="p">:</span> <span class="nf">rgb</span><span class="p">(</span><span class="m">255</span><span class="o">,</span> <span class="m">8</span><span class="o">,</span> <span class="m">8</span><span class="p">)</span><span class="err">;</span>
<span class="nv">$warning</span><span class="p">:</span> <span class="mh">#e2ff24</span><span class="err">;</span>
<span class="nv">$success</span><span class="p">:</span> <span class="mh">#b56aff</span><span class="err">;</span>

<span class="k">@import</span> <span class="s">"../node_modules/bootstrap/scss/bootstrap";</span>

<span class="nt">html</span> <span class="err">{</span>
  <span class="nl">font-size</span><span class="p">:</span> <span class="m">14px</span><span class="err">;</span>
<span class="err">}</span>

<span class="k">@media</span> <span class="p">(</span><span class="n">min-width</span><span class="o">:</span> <span class="m">768px</span><span class="p">)</span> <span class="err">{</span>
  <span class="nt">html</span> <span class="err">{</span>
    <span class="nl">font-size</span><span class="p">:</span> <span class="m">16px</span><span class="err">;</span>
  <span class="err">}</span>
<span class="err">}</span>

<span class="nc">.btn</span><span class="nd">:focus</span><span class="o">,</span> <span class="nc">.btn</span><span class="nd">:active:focus</span><span class="o">,</span> <span class="nc">.btn-link.nav-link</span><span class="nd">:focus</span><span class="o">,</span> <span class="nc">.form-control</span><span class="nd">:focus</span><span class="o">,</span> <span class="nc">.form-check-input</span><span class="nd">:focus</span> <span class="err">{</span>
  <span class="nl">box-shadow</span><span class="p">:</span> <span class="m">0</span> <span class="m">0</span> <span class="m">0</span> <span class="m">0</span><span class="mi">.1rem</span> <span class="no">white</span><span class="o">,</span> <span class="m">0</span> <span class="m">0</span> <span class="m">0</span> <span class="m">0</span><span class="mi">.25rem</span> <span class="mh">#258cfb</span><span class="err">;</span>
<span class="err">}</span>

<span class="nt">html</span> <span class="err">{</span>
  <span class="nl">position</span><span class="p">:</span> <span class="nb">relative</span><span class="err">;</span>
  <span class="nl">min-height</span><span class="p">:</span> <span class="m">100%</span><span class="err">;</span>
<span class="err">}</span>

<span class="nt">body</span> <span class="err">{</span>
  <span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">60px</span><span class="err">;</span>
<span class="err">}</span></code></pre></figure>

<p>This is an example of the “option A” approach in <a href="https://getbootstrap.com/docs/5.3/customize/sass/">Sass (Bootstrap docs)</a>:</p>

<figure class="highlight"><pre><code class="language-sass" data-lang="sass"><span class="c1">// Custom.scss</span>
<span class="c1">// Option A: Include all of Bootstrap</span>

<span class="c1">// Include any default variable overrides here (though functions won’t be available)</span>

<span class="k">@import</span> <span class="s">"../node_modules/bootstrap/scss/bootstrap";</span>

<span class="c1">// Then add additional custom code here</span></code></pre></figure>

<p>The additional custom code here is from the original <code class="language-plaintext highlighter-rouge">wwwroot/css/site.css</code> that was deleted. After adding the script to build Bootstrap from <code class="language-plaintext highlighter-rouge">node_modules</code> using Sass to <code class="language-plaintext highlighter-rouge">wwwroot</code>, this is the <code class="language-plaintext highlighter-rouge">package.json</code> (also with scripts to copy the Bootstrap JavaScript assets-<code class="language-plaintext highlighter-rouge">&lt;script src="~/js/bootstrap.bundle.min.js"&gt;&lt;/script&gt;</code> is inside the layout <code class="language-plaintext highlighter-rouge">&lt;/body&gt;</code>):</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"health"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build:css"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sass scss/site.scss wwwroot/css/site.css"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-win"</span><span class="p">:</span><span class="w"> </span><span class="s2">"copy </span><span class="se">\"</span><span class="s2">node_modules</span><span class="se">\\</span><span class="s2">bootstrap</span><span class="se">\\</span><span class="s2">dist</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2"> </span><span class="se">\"</span><span class="s2">wwwroot</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cp node_modules/bootstrap/dist/js/bootstrap.bundle.min.js wwwroot/js/bootstrap.bundle.min.js"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-assets-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npm run build:css &amp;&amp; npm run build-bootstrap-js-linux"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"keywords"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
  </span><span class="nl">"author"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"license"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ISC"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"sass"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.94.2"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@popperjs/core"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^2.11.8"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"bootstrap"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^5.3.8"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span></code></pre></figure>

<p>Having Sass compile the Bootstrap to the same path and file name as what <code class="language-plaintext highlighter-rouge">dotnet new</code> means the existing <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> will pull in the stylesheet.</p>

<h1 id="adding-aspnet-core-identity-and-entity-framework-core">Adding ASP.NET Core Identity and Entity Framework Core</h1>

<p>Next, these packages were added:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">dotnet add package Microsoft.EntityFrameworkCore <span class="nt">--version</span> 8
dotnet add package Microsoft.EntityFrameworkCore.Design <span class="nt">--version</span> 8
dotnet add package Microsoft.EntityFrameworkCore.Sqlite <span class="nt">--version</span> 8
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore <span class="nt">--version</span> 8</code></pre></figure>

<p>To save time adding <code class="language-plaintext highlighter-rouge">using</code> statements later, I add a <code class="language-plaintext highlighter-rouge">GlobalUsings.cs</code> with these since the types will be used throughout virtually every C# file in the project:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc.RazorPages</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Identity</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Identity.EntityFrameworkCore</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.EntityFrameworkCore</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">health.Data</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">health.Models</span><span class="p">;</span></code></pre></figure>

<p>There needs to be a application class that derives from EF Core’s <code class="language-plaintext highlighter-rouge">DbContext</code>; but in this case to use EF Core with ASP.NET Core Identity, it derives from <code class="language-plaintext highlighter-rouge">IdentityDbContext</code> using a class <code class="language-plaintext highlighter-rouge">User</code> that derives from <code class="language-plaintext highlighter-rouge">IdentityUser</code>.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">namespace</span> <span class="nn">health.Data</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">Db</span><span class="p">(</span><span class="n">DbContextOptions</span><span class="p">&lt;</span><span class="n">Db</span><span class="p">&gt;</span> <span class="n">options</span><span class="p">)</span> <span class="p">:</span> <span class="n">IdentityDbContext</span><span class="p">&lt;</span><span class="n">Models</span><span class="p">.</span><span class="n">User</span><span class="p">&gt;(</span><span class="n">options</span><span class="p">)</span>
<span class="p">{</span>
<span class="p">}</span></code></pre></figure>

<p>When the initial migration is scaffolded later with <code class="language-plaintext highlighter-rouge">dotnet-ef</code>, the target SQLite schema will include tables for ASP.NET Core Identity.</p>

<p>In <code class="language-plaintext highlighter-rouge">appsettings.json</code>, a default connection string is needed:</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="nl">"ConnectionStrings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"DefaultConnection"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Data Source=health.db"</span><span class="w">
  </span><span class="p">}</span></code></pre></figure>

<p>However, I only want that to be for development, so in <code class="language-plaintext highlighter-rouge">Program.cs</code> the connection string is taken from an environment variable if it’s the production environment:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">string</span> <span class="n">connectionString</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>

<span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">connectionString</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"DefaultConnection"</span><span class="p">)</span>
    <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span>
        <span class="s">"Is SQLite connection string in appsettings.json missing?"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsProduction</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">connectionString</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"health_DATABASE_PATH"</span><span class="p">]</span>
        <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span>
            <span class="s">"Is health_DATABASE_CONNECTION environment variable present?"</span><span class="p">);</span>
<span class="p">}</span></code></pre></figure>

<p>This is also needed to register <code class="language-plaintext highlighter-rouge">Db</code> and ASP.NET Core Identity services into the DI container:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">Db</span><span class="p">&gt;(</span><span class="n">options</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">options</span><span class="p">.</span><span class="nf">UseSqlite</span><span class="p">(</span><span class="n">connectionString</span><span class="p">);</span>
<span class="p">});</span>

<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDefaultIdentity</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;(</span><span class="n">options</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">options</span><span class="p">.</span><span class="n">SignIn</span><span class="p">.</span><span class="n">RequireConfirmedAccount</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span>
<span class="p">})</span>              <span class="p">.</span><span class="n">AddEntityFrameworkStores</span><span class="p">&lt;</span><span class="n">Db</span><span class="p">&gt;();</span></code></pre></figure>

<p>This command scaffolds the initial migration that will create the database:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">dotnet ef migrations add Initial</code></pre></figure>

<p>and in <code class="language-plaintext highlighter-rouge">Program.cs</code>, after the app is created, the migrations can be run like so inside a service scope:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="k">using</span> <span class="nn">IServiceScope</span> <span class="n">scope</span> <span class="p">=</span> <span class="n">app</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">CreateScope</span><span class="p">();</span>
<span class="n">Db</span> <span class="n">db</span> <span class="p">=</span> <span class="n">scope</span><span class="p">.</span><span class="n">ServiceProvider</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">Db</span><span class="p">&gt;();</span>
<span class="n">db</span><span class="p">.</span><span class="n">Database</span><span class="p">.</span><span class="nf">Migrate</span><span class="p">();</span></code></pre></figure>

<p>so starting the app will create <code class="language-plaintext highlighter-rouge">health.db</code>.</p>

<p>Finally, pages can be added for login, register, and logout, which are just custom Razor Pages endpoints that use ASP.NET Core Identity services <code class="language-plaintext highlighter-rouge">UserManager</code> and <code class="language-plaintext highlighter-rouge">SignInManager</code> (see <a href="https://github.com/eggrain/health/commit/48ed0bbd295611c0d6b7d7011a661fcf8940f998#diff-317b0c806de83ddb10a08a02a869e38eace4196db7b115dc9771ac805217a1a8">this commit</a> for the examples). Nav links for those pages were added to the layout as well with some conditional rendering logic (in the Bootstrap navbar):</p>

<figure class="highlight"><pre><code class="language-html" data-lang="html"><span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"navbar-nav flex-grow-1"</span><span class="nt">&gt;</span>
    @if (User.Identity?.IsAuthenticated == true)
    {
        <span class="nt">&lt;li</span> <span class="na">class=</span><span class="s">"nav-item"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"nav-link"</span> <span class="na">asp-area=</span><span class="s">""</span> <span class="na">asp-page=</span><span class="s">"/Index"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;</span>
        <span class="nt">&lt;/li&gt;</span>
    }
    else
    {
        <span class="nt">&lt;li</span> <span class="na">class=</span><span class="s">"nav-item"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"nav-link"</span> <span class="na">asp-page=</span><span class="s">"/Identity/Register"</span><span class="nt">&gt;</span>Register<span class="nt">&lt;/a&gt;</span>
        <span class="nt">&lt;/li&gt;</span>
        <span class="nt">&lt;li</span> <span class="na">class=</span><span class="s">"nav-item"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"nav-link"</span> <span class="na">asp-page=</span><span class="s">"/Identity/Login"</span><span class="nt">&gt;</span>Login<span class="nt">&lt;/a&gt;</span>
        <span class="nt">&lt;/li&gt;</span>
    }

    @if (User.Identity?.IsAuthenticated == true)
    {
        <span class="nt">&lt;li</span> <span class="na">class=</span><span class="s">"nav-item d-flex align-items-center"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"post"</span> <span class="na">asp-page=</span><span class="s">"/Identity/Logout"</span> <span class="na">class=</span><span class="s">"d-inline"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">class=</span><span class="s">"nav-link btn btn-link p-0"</span><span class="nt">&gt;</span>
                    Log out
                <span class="nt">&lt;/button&gt;</span>
            <span class="nt">&lt;/form&gt;</span>
        <span class="nt">&lt;/li&gt;</span>
    }
<span class="nt">&lt;/ul&gt;</span></code></pre></figure>

<p>With all that setup, the app has a database and ability to register, login, and logout users.</p>

<h1 id="initial-work-on-the-app">Initial work on the app</h1>

<p>With this app, all the data will belong to a specific user. So all entities can derive from a class with a <code class="language-plaintext highlighter-rouge">UserId</code> foreign key property:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">using</span> <span class="nn">System.ComponentModel.DataAnnotations.Schema</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">health.Models</span><span class="p">;</span>

<span class="k">public</span> <span class="k">abstract</span> <span class="k">class</span> <span class="nc">Entity</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">Id</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="n">Guid</span><span class="p">.</span><span class="nf">NewGuid</span><span class="p">().</span><span class="nf">ToString</span><span class="p">();</span>

    <span class="p">[</span><span class="nf">ForeignKey</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">User</span><span class="p">))]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">UserId</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>

    <span class="k">public</span> <span class="n">User</span> <span class="n">User</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>
<span class="p">}</span></code></pre></figure>

<p>and rather than decorate all the pages with <code class="language-plaintext highlighter-rouge">[Authorize]</code> individually, that can be applied to a parent class for page models where the user must be authenticated to access, with some methods to access the current user:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">using</span> <span class="nn">System.Security.Claims</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Authorization</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">health.Pages</span><span class="p">;</span>

<span class="p">[</span><span class="n">Authorize</span><span class="p">]</span>
<span class="k">public</span> <span class="k">abstract</span> <span class="k">class</span> <span class="nc">AuthedPageModel</span><span class="p">(</span>
    <span class="n">Db</span> <span class="n">db</span><span class="p">,</span>
    <span class="n">UserManager</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;</span> <span class="n">userManager</span><span class="p">)</span> <span class="p">:</span> <span class="n">PageModel</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="k">readonly</span> <span class="n">Db</span> <span class="n">Db</span> <span class="p">=</span> <span class="n">db</span><span class="p">;</span>
    <span class="k">protected</span> <span class="k">readonly</span> <span class="n">UserManager</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;</span> <span class="n">UserManager</span> <span class="p">=</span> <span class="n">userManager</span><span class="p">;</span>

    <span class="k">private</span> <span class="kt">string</span><span class="p">?</span> <span class="n">_currentUserId</span><span class="p">;</span>

    <span class="k">protected</span> <span class="kt">string</span> <span class="n">CurrentUserId</span> <span class="p">=&gt;</span>
        <span class="n">_currentUserId</span> <span class="p">??=</span> 
            <span class="n">User</span><span class="p">.</span><span class="nf">FindFirstValue</span><span class="p">(</span><span class="n">ClaimTypes</span><span class="p">.</span><span class="n">NameIdentifier</span><span class="p">)</span>
            <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span>
                <span class="s">"No name identifier claim to get current user ID."</span>
            <span class="p">);</span>


    <span class="k">private</span> <span class="n">User</span><span class="p">?</span> <span class="n">_currentUser</span><span class="p">;</span>

    <span class="k">protected</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;</span> <span class="nf">GetCurrentUserAsync</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">_currentUser</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">_currentUser</span><span class="p">;</span>

        <span class="n">_currentUser</span> <span class="p">=</span> <span class="k">await</span> <span class="n">UserManager</span><span class="p">.</span><span class="nf">FindByIdAsync</span><span class="p">(</span><span class="n">CurrentUserId</span><span class="p">)</span>
            <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span>
                <span class="s">$"User with id </span><span class="p">{</span><span class="n">CurrentUserId</span><span class="p">}</span><span class="s"> not found."</span>
            <span class="p">);</span>

        <span class="k">return</span> <span class="n">_currentUser</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></figure>

<p>Since the user has to be authenticated for these page models, the <code class="language-plaintext highlighter-rouge">!</code> “trust me this is not null” is OK as there should be a user. Private backing fields/memoization is also used here to avoid redundantly retrieving the current user.</p>

<p>The page model for the index page of the site was also updated to redirect to a different dashboard page for authenticated users:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">namespace</span> <span class="nn">health.Pages</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">IndexModel</span><span class="p">(</span><span class="n">ILogger</span><span class="p">&lt;</span><span class="n">IndexModel</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">)</span> <span class="p">:</span> <span class="n">PageModel</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">IndexModel</span><span class="p">&gt;</span> <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>

    <span class="k">public</span> <span class="n">IActionResult</span> <span class="nf">OnGet</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">User</span><span class="p">.</span><span class="n">Identity</span><span class="p">?.</span><span class="n">IsAuthenticated</span> <span class="p">==</span> <span class="k">true</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="nf">RedirectToPage</span><span class="p">(</span><span class="s">"/Dashboard/Index"</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="nf">Page</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></figure>

<p>With this initial work, a single weight-tracking feature was developed: a card in the dashboard which allows entering weight measurements and a weight history page with a graph and a table of individual data points that can be deleted:</p>

<p><img src="assets/screenshots/health/dashboard-card.png" alt="Weight card in dashboard" /></p>

<p><img src="assets/screenshots/health/weight-history.png" alt="Weight history" /></p>

<h1 id="deployment">Deployment</h1>

<p>Namecheap A record: Host is the subdomain string, Value is the VM’s IP address. After the app runs as a <code class="language-plaintext highlighter-rouge">systemd</code> service,</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="o">[</span>Unit]
<span class="nv">Description</span><span class="o">=</span>appname

<span class="o">[</span>Service]
<span class="nv">Type</span><span class="o">=</span>simple
<span class="nv">WorkingDirectory</span><span class="o">=</span>/home/.../bin/Release/net8.0/linux-arm64/publish
<span class="nv">User</span><span class="o">=</span>username
<span class="nv">ExecStart</span><span class="o">=</span>/home/.../bin/Release/net8.0/linux-arm64/publish/health
<span class="nv">Environment</span><span class="o">=</span><span class="nv">ASPNETCORE_URLS</span><span class="o">=</span><span class="s2">"http://0.0.0.0:5777"</span>
<span class="nv">Environment</span><span class="o">=</span><span class="nv">health_DATABASE_PATH</span><span class="o">=</span><span class="s2">"Data Source=/.../health/health.db"</span>

<span class="o">[</span>Install]
<span class="nv">WantedBy</span><span class="o">=</span>multi-user.target</code></pre></figure>

<p>The folder may need to be set with the right permissions as the containing folder may belong to <code class="language-plaintext highlighter-rouge">root</code>.</p>

<p>Apache: reverse proxy to the app listening on the specified port by the environment variable in the service file. Once the site is served over HTTP, the command to get the Let’s Encrypt certificate is run, and the HTTP Apache <code class="language-plaintext highlighter-rouge">.conf</code> for the site is edited to redirect to HTTPS.</p>]]></content><author><name></name></author><category term="programming" /><category term="c#" /><summary type="html"><![CDATA[I’ve developed a few different apps with Razor Pages now. From my experiences, I’ve gravitated toward a specific monolithic architecture, set of dependencies, and continuous deployment approach that works for me as a solo developer working on hobby projects.]]></summary></entry><entry><title type="html">Customizing the Trix editor heading button (Razor pages)</title><link href="https://eggrain.github.io/customizing-header-trix-editor-razor-pages" rel="alternate" type="text/html" title="Customizing the Trix editor heading button (Razor pages)" /><published>2025-06-23T14:35:00+00:00</published><updated>2025-06-23T14:35:00+00:00</updated><id>https://eggrain.github.io/customizing-trix-editor-in-razor-pages</id><content type="html" xml:base="https://eggrain.github.io/customizing-header-trix-editor-razor-pages"><![CDATA[<p>After doing the initial work of integrating the Trix editor for editing the content of posts in my app recently, there was an issue with the heading functionality with the approach to customizing Trix I was using so that the editor used <code class="language-plaintext highlighter-rouge">h2</code> instead of <code class="language-plaintext highlighter-rouge">h1</code> (with the toolbar unchanged). The problem was that when the Trix content was loaded into the <code class="language-plaintext highlighter-rouge">&lt;trix-editor&gt;</code>, the heading did not preserve itself, so while headings could be added to the content and displayed correctly, if the post was edited later, then the headings would have to be set again.</p>

<p>The initial approach I was using had the Trix editor itself coming from a CDN so this was just inside the <code class="language-plaintext highlighter-rouge">&lt;/body&gt;</code>:</p>

<figure class="highlight"><pre><code class="language-html" data-lang="html"><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"~/js/site.js"</span> <span class="na">asp-append-version=</span><span class="s">"true"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/trix@2.1.15/dist/trix.umd.min.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"~/js/trix-upload.js"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"~/js/trix-config.js"</span><span class="nt">&gt;&lt;/script&gt;</span></code></pre></figure>

<p>With <code class="language-plaintext highlighter-rouge">trix-config.js</code> having this:</p>

<figure class="highlight"><pre><code class="language-js" data-lang="js"><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">trix-initialize</span><span class="dl">"</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">Trix</span><span class="p">.</span><span class="nx">config</span><span class="p">.</span><span class="nx">blockAttributes</span><span class="p">.</span><span class="nx">heading1</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">h2</span><span class="dl">"</span><span class="p">;</span>
<span class="p">});</span></code></pre></figure>

<p>For one thing, I realized that the event being listened to probably should have been <code class="language-plaintext highlighter-rouge">trix-before-initialize</code> for this. Despite making that change, as well as many other tweaks to the approach, I could not resolve the issue while Trix was being served from the CDN.</p>

<h1 id="using-trix-as-an-npm-package-to-fix-the-problem">Using Trix as an NPM package to fix the problem</h1>

<p>I decided to try refactoring to use Trix as an NPM package. My approach was to use a <code class="language-plaintext highlighter-rouge">package.json</code> script to copy the files from <code class="language-plaintext highlighter-rouge">node_modules</code> (after installing Trix with <code class="language-plaintext highlighter-rouge">npm install Trix</code>), specifically the ES-module build of Trix (the <code class="language-plaintext highlighter-rouge">.esm</code> file):</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build-bootstrap-css"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sass scss/custom-bootstrap.scss wwwroot/css/bootstrap-custom.css"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-win"</span><span class="p">:</span><span class="w"> </span><span class="s2">"copy </span><span class="se">\"</span><span class="s2">node_modules</span><span class="se">\\</span><span class="s2">bootstrap</span><span class="se">\\</span><span class="s2">dist</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2"> </span><span class="se">\"</span><span class="s2">wwwroot</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cp node_modules/bootstrap/dist/js/bootstrap.bundle.min.js wwwroot/js/bootstrap.bundle.min.js"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-trix-js-win"</span><span class="p">:</span><span class="w">    </span><span class="s2">"copy </span><span class="se">\"</span><span class="s2">node_modules</span><span class="se">\\</span><span class="s2">trix</span><span class="se">\\</span><span class="s2">dist</span><span class="se">\\</span><span class="s2">trix.esm.min.js</span><span class="se">\"</span><span class="s2"> </span><span class="se">\"</span><span class="s2">wwwroot</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">trix.esm.min.js</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-trix-js-linux"</span><span class="p">:</span><span class="w">  </span><span class="s2">"cp node_modules/trix/dist/trix.esm.min.js wwwroot/js/trix.esm.min.js</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-trix-css-win"</span><span class="p">:</span><span class="w">   </span><span class="s2">"copy </span><span class="se">\"</span><span class="s2">node_modules</span><span class="se">\\</span><span class="s2">trix</span><span class="se">\\</span><span class="s2">dist</span><span class="se">\\</span><span class="s2">trix.css</span><span class="se">\"</span><span class="s2"> </span><span class="se">\"</span><span class="s2">wwwroot</span><span class="se">\\</span><span class="s2">css</span><span class="se">\\</span><span class="s2">trix.css</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-trix-css-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cp node_modules/trix/dist/trix.css wwwroot/css/trix.css</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">

    </span><span class="nl">"build-assets-win"</span><span class="p">:</span><span class="w">   </span><span class="s2">"npm run build-bootstrap-js-win  &amp;&amp; npm run build-trix-js-win  &amp;&amp; npm run build-trix-css-win"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-assets-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npm run build-bootstrap-js-linux &amp;&amp; npm run build-trix-js-linux &amp;&amp; npm run build-trix-css-linux"</span><span class="w">
  </span><span class="p">}</span><span class="err">,</span></code></pre></figure>

<p>This project also has bootstrap assets being copied in the same way, and versions of the script to copy for Windows (development) and Linux (GitHub Action) as well as scripts that serve to just run the Windows or Linux scripts together.</p>

<p>Now I added to <code class="language-plaintext highlighter-rouge">site.js</code> (<code class="language-plaintext highlighter-rouge">wwwroot/js/site.js</code> default Razor pages file):</p>

<figure class="highlight"><pre><code class="language-js" data-lang="js"><span class="k">import</span> <span class="nx">Trix</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./trix.esm.min.js</span><span class="dl">"</span><span class="p">;</span>

<span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">trix-before-initialize</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">Trix</span><span class="p">.</span><span class="nx">config</span><span class="p">.</span><span class="nx">blockAttributes</span><span class="p">.</span><span class="nx">heading1</span> <span class="o">=</span> <span class="p">{</span>
        <span class="na">tagName</span><span class="p">:</span>       <span class="dl">"</span><span class="s2">h2</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">parse</span><span class="p">:</span>         <span class="nx">el</span> <span class="o">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">H2</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">breakOnReturn</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="na">terminal</span><span class="p">:</span>      <span class="kc">true</span><span class="p">,</span>
        <span class="na">group</span><span class="p">:</span>         <span class="kc">false</span>
    <span class="p">};</span>
<span class="p">})</span></code></pre></figure>

<p>and removed the <code class="language-plaintext highlighter-rouge">trix-config.js</code> (the change to this from the previous event listener was part of what I was trying while Trix was from the CDN). To use <code class="language-plaintext highlighter-rouge">import</code> requires <code class="language-plaintext highlighter-rouge">type="module"</code> (ECMAScript modules) so here is what went just inside <code class="language-plaintext highlighter-rouge">&lt;/body&gt;</code>:</p>

<figure class="highlight"><pre><code class="language-html" data-lang="html"><span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"module"</span> <span class="na">src=</span><span class="s">"~/js/site.js"</span> <span class="na">asp-append-version=</span><span class="s">"true"</span><span class="nt">&gt;&lt;/script&gt;</span>
<span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"~/js/trix-upload.js"</span> <span class="na">defer</span><span class="nt">&gt;&lt;/script&gt;</span></code></pre></figure>

<p>With this approach, the goal of customizing the Trix editor to use <code class="language-plaintext highlighter-rouge">h2</code> instead of <code class="language-plaintext highlighter-rouge">h1</code> was accomplished without the issue of the heading not being preserved on editing the post content.</p>]]></content><author><name></name></author><category term="programming" /><category term="c#" /><summary type="html"><![CDATA[After doing the initial work of integrating the Trix editor for editing the content of posts in my app recently, there was an issue with the heading functionality with the approach to customizing Trix I was using so that the editor used h2 instead of h1 (with the toolbar unchanged). The problem was that when the Trix content was loaded into the &lt;trix-editor&gt;, the heading did not preserve itself, so while headings could be added to the content and displayed correctly, if the post was edited later, then the headings would have to be set again.]]></summary></entry><entry><title type="html">Explanation of a Program.cs file</title><link href="https://eggrain.github.io/programcs" rel="alternate" type="text/html" title="Explanation of a Program.cs file" /><published>2025-06-18T20:35:00+00:00</published><updated>2025-06-18T20:35:00+00:00</updated><id>https://eggrain.github.io/programcs</id><content type="html" xml:base="https://eggrain.github.io/programcs"><![CDATA[<p>You’ll often see me refer to Program.cs in my blog posts. It’s the entry point of a .NET executable program and defines how the application is configured and started. In modern C# projects, top-level statements are used here instead of a traditional <code class="language-plaintext highlighter-rouge">Program</code> class with a <code class="language-plaintext highlighter-rouge">Main</code> method (this is typically the only file in the project that uses top-level statements). This blog post is an explanation of this <code class="language-plaintext highlighter-rouge">Program.cs</code> from a Todo list app I’ve been working on recently:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="c1">// Add services to the container.</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddRazorPages</span><span class="p">();</span>

<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;(</span><span class="n">options</span> <span class="p">=&gt;</span>
    <span class="n">options</span><span class="p">.</span><span class="nf">UseSqlite</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"DefaultConnection"</span><span class="p">)));</span>

<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddIdentity</span><span class="p">&lt;</span><span class="n">AppUser</span><span class="p">,</span> <span class="n">IdentityRole</span><span class="p">&gt;(</span>
    <span class="n">options</span> <span class="p">=&gt;</span> <span class="n">options</span><span class="p">.</span><span class="n">SignIn</span><span class="p">.</span><span class="n">RequireConfirmedAccount</span> <span class="p">=</span> <span class="k">false</span><span class="p">)</span>
        <span class="p">.</span><span class="n">AddEntityFrameworkStores</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;();</span>

<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="k">using</span> <span class="nn">IServiceScope</span> <span class="n">scope</span> <span class="p">=</span> <span class="n">app</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">CreateScope</span><span class="p">();</span>
<span class="n">AppDbContext</span> <span class="n">db</span> <span class="p">=</span> <span class="n">scope</span><span class="p">.</span><span class="n">ServiceProvider</span>
                <span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;();</span>
<span class="n">db</span><span class="p">.</span><span class="n">Database</span><span class="p">.</span><span class="nf">Migrate</span><span class="p">();</span>

<span class="c1">// Configure the HTTP request pipeline.</span>
<span class="k">if</span> <span class="p">(!</span><span class="n">app</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">UseExceptionHandler</span><span class="p">(</span><span class="s">"/Error"</span><span class="p">);</span>
    <span class="c1">// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">UseHsts</span><span class="p">();</span>
<span class="p">}</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseHttpsRedirection</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseStaticFiles</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseRouting</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthentication</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthorization</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">MapRazorPages</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span></code></pre></figure>

<p>This includes some of the comments that come with a new project scaffolded by <code class="language-plaintext highlighter-rouge">dotnet new</code>.</p>

<h1 id="explanation">Explanation</h1>

<p>This Program.cs doesn’t include individual using statements because they’re defined globally in a <code class="language-plaintext highlighter-rouge">GlobalUsings.cs</code> file:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Identity</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">todolist.Data</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">todolist.Models</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">global</span> <span class="k">using</span> <span class="nn">Microsoft.EntityFrameworkCore</span><span class="p">;</span></code></pre></figure>

<p>Global usings are a .NET 6+ feature that apply using directives project-wide, reducing boilerplate.</p>

<p>Typically namespaces in C# projects follow the folders’ structure, so the <code class="language-plaintext highlighter-rouge">Models</code> folder has the namespace <code class="language-plaintext highlighter-rouge">appname.Models</code>, for example. EF Core is an object-relational mapping (ORM) library for working with databases using C# classes. ASP.NET Core Identity is a library within ASP.NET Core that provides authentication and user management features. ASP.NET Core MVC is a framework within ASP.NET Core used to build web apps using the MVC pattern. Razor Pages, which this project uses, is built on top of ASP.NET Core MVC, focusing on page-based routing rather than controllers.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span></code></pre></figure>

<p>Instantiates the app builder, which has a <code class="language-plaintext highlighter-rouge">Configuration</code> property to access the configuration, and is used to register services into the Dependency Injection (DI) container. <code class="language-plaintext highlighter-rouge">builder</code> is a <code class="language-plaintext highlighter-rouge">WebApplicationBuilder</code>.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddRazorPages</span><span class="p">();</span></code></pre></figure>

<p>Registers support for Razor Pages, including the services and routing needed to handle <code class="language-plaintext highlighter-rouge">.cshtml</code> pages. In general, <code class="language-plaintext highlighter-rouge">WebApplicationBuilder</code> methods which start with <code class="language-plaintext highlighter-rouge">Add</code> are registering services into the DI container.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;(</span><span class="n">options</span> <span class="p">=&gt;</span>
    <span class="n">options</span><span class="p">.</span><span class="nf">UseSqlite</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"DefaultConnection"</span><span class="p">)));</span></code></pre></figure>

<p>Registers <code class="language-plaintext highlighter-rouge">AppDbContext</code> (class derived from EF Core <code class="language-plaintext highlighter-rouge">DbContext</code>) with an SQLite connection string taken from the config.</p>

<h2 id="aside-on-getconnectionstring">Aside on GetConnectionString</h2>

<p><code class="language-plaintext highlighter-rouge">GetConnectionString</code> is a good example of .NET development having a very big API. GetConnectionString is a wrapper for accessing connection strings via configuration, and its use means <code class="language-plaintext highlighter-rouge">appsettings.json</code> has <code class="language-plaintext highlighter-rouge">"ConnectionStrings"</code>:</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"Logging"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"LogLevel"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"Default"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Information"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Microsoft.AspNetCore"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Warning"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"AllowedHosts"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"ConnectionStrings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"DefaultConnection"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Data Source=todolist.db"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span></code></pre></figure>

<p>In my opinion, the more generally applicable syntax <code class="language-plaintext highlighter-rouge">builder.Configuration["ConnectionStrings:DefaultConnection"]</code> works just as well. Since <code class="language-plaintext highlighter-rouge">GetConnectionString</code> is a recognizable method, I use it sometimes anyway.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddIdentity</span><span class="p">&lt;</span><span class="n">AppUser</span><span class="p">,</span> <span class="n">IdentityRole</span><span class="p">&gt;(</span>
    <span class="n">options</span> <span class="p">=&gt;</span> <span class="n">options</span><span class="p">.</span><span class="n">SignIn</span><span class="p">.</span><span class="n">RequireConfirmedAccount</span> <span class="p">=</span> <span class="k">false</span><span class="p">)</span>
        <span class="p">.</span><span class="n">AddEntityFrameworkStores</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;();</span></code></pre></figure>

<p>Registers ASP.NET Core Identity services with <code class="language-plaintext highlighter-rouge">AppUser</code> (class derived from ASP.NET Core Identity <code class="language-plaintext highlighter-rouge">IdentityUser</code>) and the default ASP.NET Core Identity <code class="language-plaintext highlighter-rouge">IdentityRole</code> class. The <code class="language-plaintext highlighter-rouge">.AddEntityFrameworkStores&lt;AppDbContext&gt;</code> is what makes ASP.NET Core Identity work with the database EF Core is configured to.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span></code></pre></figure>

<p>Builds the app with its configuration, registered services, and middleware. <code class="language-plaintext highlighter-rouge">app</code> is a <code class="language-plaintext highlighter-rouge">WebApplication</code>.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">using</span> <span class="nn">IServiceScope</span> <span class="n">scope</span> <span class="p">=</span> <span class="n">app</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">CreateScope</span><span class="p">();</span>
<span class="n">AppDbContext</span> <span class="n">db</span> <span class="p">=</span> <span class="n">scope</span><span class="p">.</span><span class="n">ServiceProvider</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;();</span>
<span class="n">db</span><span class="p">.</span><span class="n">Database</span><span class="p">.</span><span class="nf">Migrate</span><span class="p">();</span></code></pre></figure>

<p>Creates a service scope, gets the <code class="language-plaintext highlighter-rouge">AppDbContext</code>, and applies any pending migrations to ensure the database is up-to-date.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">if</span> <span class="p">(!</span><span class="n">app</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">UseExceptionHandler</span><span class="p">(</span><span class="s">"/Error"</span><span class="p">);</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">UseHsts</span><span class="p">();</span>
<span class="p">}</span></code></pre></figure>

<p>If not in development, uses a generic error handler and enables HTTP Strict Transport Security. This is part of the default <code class="language-plaintext highlighter-rouge">Program.cs</code> scaffolded by <code class="language-plaintext highlighter-rouge">dotnet new</code>.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">UseHttpsRedirection</span><span class="p">();</span></code></pre></figure>

<p>Redirects all HTTP requests to HTTPS.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">UseStaticFiles</span><span class="p">();</span></code></pre></figure>

<p>Enables serving static files (e.g. CSS, JS, images) from <code class="language-plaintext highlighter-rouge">wwwroot</code>.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">UseRouting</span><span class="p">();</span></code></pre></figure>

<p>Enables endpoint routing system.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">UseAuthentication</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthorization</span><span class="p">();</span></code></pre></figure>

<p>Adds authentication and authorization to the middleware pipeline (ASP.NET Core Identity). It is important to call these methods in this order since authentication must happen before authorization.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">MapRazorPages</span><span class="p">();</span></code></pre></figure>

<p>Maps Razor Pages to routes in the app.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span></code></pre></figure>

<p>Starts the web application.</p>]]></content><author><name></name></author><category term="programming" /><category term="c#" /><category term="razor pages" /><summary type="html"><![CDATA[You’ll often see me refer to Program.cs in my blog posts. It’s the entry point of a .NET executable program and defines how the application is configured and started. In modern C# projects, top-level statements are used here instead of a traditional Program class with a Main method (this is typically the only file in the project that uses top-level statements). This blog post is an explanation of this Program.cs from a Todo list app I’ve been working on recently:]]></summary></entry><entry><title type="html">File uploads in lilgobguides (Razor Pages)</title><link href="https://eggrain.github.io/file-uploads-lilgobguides" rel="alternate" type="text/html" title="File uploads in lilgobguides (Razor Pages)" /><published>2025-06-14T20:35:00+00:00</published><updated>2025-06-14T20:35:00+00:00</updated><id>https://eggrain.github.io/file-uploads-in-lilgobguides</id><content type="html" xml:base="https://eggrain.github.io/file-uploads-lilgobguides"><![CDATA[<p>In my Old School Runescape blog website lilgobguides, there are two different mechanisms for file uploads which I developed recently, and so I wanted to review and compare them. The first is a normal file upload control in a form for <code class="language-plaintext highlighter-rouge">Post</code>s, and there is a Trix editor for <code class="language-plaintext highlighter-rouge">Post</code>s which does file uploads and deletes. The image data is stored on the server differently in each case. I also include a snippet at the end for a third way to store image uploads, taken from a different project.</p>

<h1 id="normal-form-with-file-upload">Normal form with file upload</h1>

<p>For an HTML form or <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> to be able to upload a file, the <code class="language-plaintext highlighter-rouge">enctype</code> attribute must be set to <code class="language-plaintext highlighter-rouge">"multipart/form-data"</code> (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype">HTMLFormElement: enctype property</a>)</p>

<figure class="highlight"><pre><code class="language-html" data-lang="html"><span class="nt">&lt;form</span> <span class="na">method=</span><span class="s">"post"</span> <span class="na">enctype=</span><span class="s">"multipart/form-data"</span><span class="nt">&gt;</span></code></pre></figure>

<p>Then in the form there is this input for file upload:</p>

<figure class="highlight"><pre><code class="language-html" data-lang="html"><span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"HeaderImageFile"</span> <span class="na">class=</span><span class="s">"form-label"</span><span class="nt">&gt;</span>Post Card Header Image<span class="nt">&lt;/label&gt;</span>
<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"file"</span> <span class="na">class=</span><span class="s">"form-control"</span> <span class="na">id=</span><span class="s">"HeaderImageFile"</span> <span class="na">name=</span><span class="s">"HeaderImageFile"</span> <span class="nt">/&gt;</span></code></pre></figure>

<p>The page model (like a page controller) has a public property with a public property setter (<code class="language-plaintext highlighter-rouge">set</code> instead of <code class="language-plaintext highlighter-rouge">private set;</code>) which gets an <code class="language-plaintext highlighter-rouge">IFormFile</code> from model binding:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="p">[</span><span class="n">Authorize</span><span class="p">]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">NewPostModel</span><span class="p">(</span><span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span> <span class="p">:</span> <span class="n">PageModel</span>
<span class="p">{</span>
    <span class="p">...</span>

    <span class="p">[</span><span class="n">BindProperty</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">IFormFile</span><span class="p">?</span> <span class="n">HeaderImageFile</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">...</span>
<span class="p">}</span></code></pre></figure>

<p>Then there is some logic in the POST action methods for new post and edit post, this is what adds the image to the <code class="language-plaintext highlighter-rouge">Post</code> object in the new post handler:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">IActionResult</span><span class="p">&gt;</span> <span class="nf">OnPostAsync</span><span class="p">()</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(!</span><span class="n">ModelState</span><span class="p">.</span><span class="n">IsValid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nf">Page</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">HeaderImageFile</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="n">Length</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">using</span> <span class="nn">var</span> <span class="n">ms</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MemoryStream</span><span class="p">();</span>
        <span class="k">await</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="nf">CopyToAsync</span><span class="p">(</span><span class="n">ms</span><span class="p">);</span>
        <span class="n">Post</span><span class="p">.</span><span class="n">HeaderImageData</span> <span class="p">=</span> <span class="n">ms</span><span class="p">.</span><span class="nf">ToArray</span><span class="p">();</span>
        <span class="n">Post</span><span class="p">.</span><span class="n">HeaderImageContentType</span> <span class="p">=</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="n">ContentType</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="p">...</span>
<span class="p">}</span></code></pre></figure>

<p>The edit post logic has this:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">if</span> <span class="p">(</span><span class="n">HeaderImageFile</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="n">Length</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">using</span> <span class="nn">var</span> <span class="n">ms</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MemoryStream</span><span class="p">();</span>
    <span class="k">await</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="nf">CopyToAsync</span><span class="p">(</span><span class="n">ms</span><span class="p">);</span>
    <span class="n">post</span><span class="p">.</span><span class="n">HeaderImageData</span> <span class="p">=</span> <span class="n">ms</span><span class="p">.</span><span class="nf">ToArray</span><span class="p">();</span>
    <span class="n">post</span><span class="p">.</span><span class="n">HeaderImageContentType</span> <span class="p">=</span> <span class="n">HeaderImageFile</span><span class="p">.</span><span class="n">ContentType</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="n">_db</span><span class="p">.</span><span class="nf">Entry</span><span class="p">(</span><span class="n">post</span><span class="p">).</span><span class="nf">Property</span><span class="p">(</span><span class="n">p</span> <span class="p">=&gt;</span> <span class="n">p</span><span class="p">.</span><span class="n">HeaderImageData</span><span class="p">).</span><span class="n">IsModified</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span>
    <span class="n">_db</span><span class="p">.</span><span class="nf">Entry</span><span class="p">(</span><span class="n">post</span><span class="p">).</span><span class="nf">Property</span><span class="p">(</span><span class="n">p</span> <span class="p">=&gt;</span> <span class="n">p</span><span class="p">.</span><span class="n">HeaderImageContentType</span><span class="p">).</span><span class="n">IsModified</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span>
<span class="p">}</span></code></pre></figure>

<p>This makes it so if a post is edited without uploading a new file, the existing file data will not be removed when <code class="language-plaintext highlighter-rouge">await _db.SaveChangesAsync()</code> happens.</p>

<p><code class="language-plaintext highlighter-rouge">Post</code> has these two properties:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">public</span> <span class="kt">byte</span><span class="p">[]?</span> <span class="n">HeaderImageData</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="k">public</span> <span class="kt">string</span><span class="p">?</span> <span class="n">HeaderImageContentType</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span></code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">Post</code> is also an EF Core entity type that has a table <code class="language-plaintext highlighter-rouge">Posts</code> in the SQLite database using EF Core.</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">public</span> <span class="k">class</span> <span class="nc">AppDbContext</span><span class="p">(</span><span class="n">DbContextOptions</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;</span> <span class="n">options</span><span class="p">)</span>
                                    <span class="p">:</span> <span class="nf">IdentityDbContext</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">DbSet</span><span class="p">&lt;</span><span class="n">Post</span><span class="p">&gt;</span> <span class="n">Posts</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="p">...</span>
<span class="p">}</span></code></pre></figure>

<p>Thus there is this table (from the <code class="language-plaintext highlighter-rouge">sqlite_master</code> SQLite system catalog):</p>

<figure class="highlight"><pre><code class="language-sql" data-lang="sql"><span class="k">type</span>   <span class="n">name</span>                                      <span class="n">tbl_name</span>               <span class="n">rootpage</span>  <span class="k">sql</span> 
<span class="k">table</span>  <span class="n">Posts</span>                                     <span class="n">Posts</span>                  <span class="mi">29</span>        <span class="k">CREATE</span> <span class="k">TABLE</span> <span class="nv">"Posts"</span> <span class="p">(</span>
    <span class="nv">"Id"</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">CONSTRAINT</span> <span class="nv">"PK_Posts"</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="nv">"Content"</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="nv">"CreatedAt"</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="nv">"Title"</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span>
<span class="p">,</span> <span class="nv">"HeaderImageContentType"</span> <span class="nb">TEXT</span> <span class="k">NULL</span><span class="p">,</span> <span class="nv">"HeaderImageData"</span> <span class="nb">BLOB</span> <span class="k">NULL</span><span class="p">,</span> <span class="nv">"Featured"</span> <span class="nb">INTEGER</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="mi">0</span><span class="p">)</span> </code></pre></figure>

<p>So what this shows is the file upload data is being stored as the data value in the SQLite database in a column with the <code class="language-plaintext highlighter-rouge">BLOB</code> type. An SQLite database is a single on-disk database file.</p>

<p>To use that image in the HTML, the Razor pages <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> gets a <code class="language-plaintext highlighter-rouge">src</code> like so:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="nf">@if</span> <span class="p">(</span><span class="n">Model</span><span class="p">.</span><span class="n">HeaderImageData</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">Model</span><span class="p">.</span><span class="n">HeaderImageContentType</span> <span class="p">!=</span> <span class="k">null</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">base64</span> <span class="p">=</span> <span class="n">Convert</span><span class="p">.</span><span class="nf">ToBase64String</span><span class="p">(</span><span class="n">Model</span><span class="p">.</span><span class="n">HeaderImageData</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">imgSrc</span> <span class="p">=</span> <span class="s">$"data:</span><span class="p">{</span><span class="n">Model</span><span class="p">.</span><span class="n">HeaderImageContentType</span><span class="p">}</span><span class="s">;base64,</span><span class="p">{</span><span class="n">base64</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
    <span class="p">&lt;</span><span class="n">img</span> <span class="n">src</span><span class="p">=</span><span class="s">"@imgSrc"</span> <span class="k">class</span><span class="err">="</span><span class="nc">card</span><span class="p">-</span><span class="n">img</span><span class="p">-</span><span class="n">top</span><span class="s">" alt="</span><span class="n">Header</span> <span class="n">Image</span><span class="s">" /&gt;
</span><span class="p">}</span></code></pre></figure>

<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data">data: URLS</a> MDN article explains the <code class="language-plaintext highlighter-rouge">src</code> syntax there.</p>

<h1 id="trix-editor-file-uploads-and-deletes">Trix editor file uploads and deletes</h1>

<p>With Trix and <code class="language-plaintext highlighter-rouge">&lt;trix-editor&gt;</code> setup, there are some Trix JavaScript events that event listeners can add <code class="language-plaintext highlighter-rouge">fetch</code> calls to:</p>

<figure class="highlight"><pre><code class="language-javascript" data-lang="javascript"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">trix-attachment-remove</span><span class="dl">"</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">attachment</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">attachment</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">getAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">url</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">url</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/uploads/trix?url=</span><span class="dl">"</span> <span class="o">+</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">url</span><span class="p">),</span> <span class="p">{</span>
            <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">DELETE</span><span class="dl">"</span>
        <span class="p">}).</span><span class="k">catch</span><span class="p">(</span><span class="nx">err</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Delete failed:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">err</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">});</span>

<span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">trix-attachment-add</span><span class="dl">"</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">attachment</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">attachment</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">attachment</span><span class="p">.</span><span class="nx">file</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">uploadTrixFile</span><span class="p">(</span><span class="nx">attachment</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">});</span>

<span class="kd">function</span> <span class="nx">uploadTrixFile</span><span class="p">(</span><span class="nx">attachment</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">file</span> <span class="o">=</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">file</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">formData</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">FormData</span><span class="p">();</span>
    <span class="nx">formData</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">file</span><span class="dl">"</span><span class="p">,</span> <span class="nx">file</span><span class="p">);</span>

    <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/uploads/trix</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
        <span class="na">body</span><span class="p">:</span> <span class="nx">formData</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Upload failed.</span><span class="dl">"</span><span class="p">);</span>
        <span class="k">return</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">data</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">attachment</span><span class="p">.</span><span class="nx">setAttributes</span><span class="p">({</span>
            <span class="na">url</span><span class="p">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span>
            <span class="na">href</span><span class="p">:</span> <span class="nx">data</span><span class="p">.</span><span class="nx">url</span>
        <span class="p">});</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Upload error:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="p">});</span>
<span class="p">}</span></code></pre></figure>

<p>To me, the simplest way to handle that on the server is with an API controller. Although <code class="language-plaintext highlighter-rouge">lilgobguides</code> is a Razor pages app, controllers can be added by registering their services and mapping their routes with <code class="language-plaintext highlighter-rouge">builder.Services.AddControllers();</code> and <code class="language-plaintext highlighter-rouge">app.MapControllers();</code> in <code class="language-plaintext highlighter-rouge">Program.cs</code>.</p>

<p>With those services in place, the specific controller for handling the Trix editor events’ <code class="language-plaintext highlighter-rouge">fetch</code> requests is:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Authorization</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">lilgobguides.Controllers</span><span class="p">;</span>

<span class="p">[</span><span class="n">ApiController</span><span class="p">,</span> <span class="nf">Route</span><span class="p">(</span><span class="s">"uploads"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">UploadsController</span><span class="p">(</span><span class="n">IWebHostEnvironment</span> <span class="n">env</span><span class="p">)</span> <span class="p">:</span> <span class="n">ControllerBase</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IWebHostEnvironment</span> <span class="n">_env</span> <span class="p">=</span> <span class="n">env</span><span class="p">;</span>

    <span class="p">[</span><span class="n">Authorize</span><span class="p">,</span> <span class="nf">HttpDelete</span><span class="p">(</span><span class="s">"trix"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="n">IActionResult</span> <span class="nf">DeleteImage</span><span class="p">([</span><span class="n">FromQuery</span><span class="p">]</span> <span class="kt">string</span> <span class="n">url</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">string</span> <span class="n">fileName</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">GetFileName</span><span class="p">(</span><span class="n">url</span><span class="p">);</span>
        <span class="kt">string</span> <span class="n">path</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span><span class="n">_env</span><span class="p">.</span><span class="n">WebRootPath</span><span class="p">,</span> <span class="s">"uploads"</span><span class="p">,</span> <span class="n">fileName</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">System</span><span class="p">.</span><span class="n">IO</span><span class="p">.</span><span class="n">File</span><span class="p">.</span><span class="nf">Exists</span><span class="p">(</span><span class="n">path</span><span class="p">))</span>
            <span class="n">System</span><span class="p">.</span><span class="n">IO</span><span class="p">.</span><span class="n">File</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="n">path</span><span class="p">);</span>

        <span class="k">return</span> <span class="nf">Ok</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="p">[</span><span class="n">Authorize</span><span class="p">,</span> <span class="nf">HttpPost</span><span class="p">(</span><span class="s">"trix"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">IActionResult</span><span class="p">&gt;</span> <span class="nf">UploadImage</span><span class="p">(</span><span class="n">IFormFile</span> <span class="n">file</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">file</span> <span class="p">==</span> <span class="k">null</span> <span class="p">||</span> <span class="n">file</span><span class="p">.</span><span class="n">Length</span> <span class="p">==</span> <span class="m">0</span><span class="p">)</span>
            <span class="k">return</span> <span class="nf">BadRequest</span><span class="p">(</span><span class="s">"No file uploaded."</span><span class="p">);</span>

        <span class="kt">string</span> <span class="n">uploadsPath</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span><span class="n">_env</span><span class="p">.</span><span class="n">WebRootPath</span><span class="p">,</span> <span class="s">"uploads"</span><span class="p">);</span>
        <span class="n">Directory</span><span class="p">.</span><span class="nf">CreateDirectory</span><span class="p">(</span><span class="n">uploadsPath</span><span class="p">);</span>

        <span class="kt">string</span> <span class="n">fileName</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">GetRandomFileName</span><span class="p">()</span> <span class="p">+</span> <span class="n">Path</span><span class="p">.</span><span class="nf">GetExtension</span><span class="p">(</span><span class="n">file</span><span class="p">.</span><span class="n">FileName</span><span class="p">);</span>
        <span class="kt">string</span> <span class="n">filePath</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span><span class="n">uploadsPath</span><span class="p">,</span> <span class="n">fileName</span><span class="p">);</span>

        <span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">stream</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">FileStream</span><span class="p">(</span><span class="n">filePath</span><span class="p">,</span> <span class="n">FileMode</span><span class="p">.</span><span class="n">Create</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">file</span><span class="p">.</span><span class="nf">CopyToAsync</span><span class="p">(</span><span class="n">stream</span><span class="p">);</span>

        <span class="kt">string</span> <span class="n">fileUrl</span> <span class="p">=</span> <span class="n">Url</span><span class="p">.</span><span class="nf">Content</span><span class="p">(</span><span class="s">$"~/uploads/</span><span class="p">{</span><span class="n">fileName</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">Ok</span><span class="p">(</span><span class="k">new</span> <span class="p">{</span> <span class="n">url</span> <span class="p">=</span> <span class="n">fileUrl</span> <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">[Authorize]</code> enforces simple authorization (authentication) on both controller actions. <code class="language-plaintext highlighter-rouge">[ApiController]</code> does quite a lot including automatic 400 responses for requests failing model binding and conventional routing based on the class name.</p>

<p>This shows the images uploaded from the Trix editor are saved on the disk in the public web <code class="language-plaintext highlighter-rouge">wwwroot</code> folder with randomly generated file names, making them static assets of the site. In the other case, they were stored as <code class="language-plaintext highlighter-rouge">BLOB</code> values in the SQLite database with image data written into the HTML during server HTML rendering.</p>

<p>An alternative to saving the images in <code class="language-plaintext highlighter-rouge">wwwroot</code> would be to store them in a different folder, and use a controller to serve them from there. I used this approach in a quick project once about a year ago:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="p">[</span><span class="n">ApiController</span><span class="p">,</span> <span class="nf">Route</span><span class="p">(</span><span class="s">"api/[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">UploadedImagesController</span><span class="p">(</span><span class="n">ILogger</span><span class="p">&lt;</span><span class="n">UploadedImagesController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">,</span> 
                                    <span class="n">IConfiguration</span> <span class="n">config</span><span class="p">,</span>
                                    <span class="n">GalleryDbContext</span> <span class="n">dbContext</span><span class="p">)</span> <span class="p">:</span> <span class="n">ControllerBase</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">UploadedImagesController</span><span class="p">&gt;</span> <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IConfiguration</span> <span class="n">_config</span> <span class="p">=</span> <span class="n">config</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">GalleryDbContext</span> <span class="n">_dbContext</span> <span class="p">=</span> <span class="n">dbContext</span><span class="p">;</span>

    <span class="p">[</span><span class="nf">HttpGet</span><span class="p">(</span><span class="s">"{imageId}"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="n">IActionResult</span> <span class="nf">Get</span><span class="p">(</span><span class="kt">string</span> <span class="n">imageId</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">UploadedImage</span><span class="p">?</span> <span class="n">image</span> <span class="p">=</span> <span class="n">_dbContext</span><span class="p">.</span><span class="n">UploadedImages</span><span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">(</span><span class="n">file</span> <span class="p">=&gt;</span> <span class="n">file</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">imageId</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">image</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span> <span class="k">return</span> <span class="nf">NotFound</span><span class="p">();</span>

        <span class="kt">string</span> <span class="n">uploadedFilesPath</span> <span class="p">=</span> <span class="n">_config</span><span class="p">.</span><span class="n">GetValue</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="s">"StoredFilesPath"</span><span class="p">)!;</span>

        <span class="kt">string</span> <span class="n">uploadedFilePath</span> <span class="p">=</span> <span class="n">image</span><span class="p">.</span><span class="nf">PathToFile</span><span class="p">(</span><span class="n">uploadedFilesPath</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(!</span><span class="n">System</span><span class="p">.</span><span class="n">IO</span><span class="p">.</span><span class="n">File</span><span class="p">.</span><span class="nf">Exists</span><span class="p">(</span><span class="n">uploadedFilePath</span><span class="p">))</span> <span class="k">return</span> <span class="nf">NotFound</span><span class="p">();</span>

        <span class="k">return</span> <span class="nf">PhysicalFile</span><span class="p">(</span><span class="n">uploadedFilePath</span><span class="p">,</span> <span class="s">"image/png"</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></figure>]]></content><author><name></name></author><category term="programming" /><category term="c#" /><category term="razor pages" /><summary type="html"><![CDATA[In my Old School Runescape blog website lilgobguides, there are two different mechanisms for file uploads which I developed recently, and so I wanted to review and compare them. The first is a normal file upload control in a form for Posts, and there is a Trix editor for Posts which does file uploads and deletes. The image data is stored on the server differently in each case. I also include a snippet at the end for a third way to store image uploads, taken from a different project.]]></summary></entry><entry><title type="html">Notes on lilgobguides build and CI/CD (Razor pages)</title><link href="https://eggrain.github.io/lilgobguides-build-and-cicd-notes" rel="alternate" type="text/html" title="Notes on lilgobguides build and CI/CD (Razor pages)" /><published>2025-06-05T20:35:00+00:00</published><updated>2025-06-05T20:35:00+00:00</updated><id>https://eggrain.github.io/lilgobguides-deployment-notes</id><content type="html" xml:base="https://eggrain.github.io/lilgobguides-build-and-cicd-notes"><![CDATA[<p>Yesterday and today I did some work on CI/CD for my Old School Runescape blog/guides website lilgobguides, which I redeveloped recently as a Razor pages app with Bootstrap and an SQLite database. This post just notes some details on what I did for that.</p>

<h2 id="bootstrap-as-an-npm-package-and-scss-compilation">Bootstrap as an NPM package and SCSS compilation</h2>

<p>There were some considerations with regards to Bootstrap; a new Razor pages app created with <code class="language-plaintext highlighter-rouge">dotnet new razor</code> has already compiled Bootstrap in the project inside <code class="language-plaintext highlighter-rouge">wwwroot/js</code> and <code class="language-plaintext highlighter-rouge">wwwroot/css</code>. This approach limits how much customization you can do (see <a href="https://getbootstrap.com/docs/5.3/customize/overview/#overview">Customize Bootstrap docs</a>). In order to customize the Bootstrap theme, I install Bootstrap as an npm package and have this <code class="language-plaintext highlighter-rouge">scss/custom-bootstrap.scss</code> in the project root:</p>

<figure class="highlight"><pre><code class="language-scss" data-lang="scss"><span class="nv">$primary</span><span class="p">:</span> <span class="mh">#ff00a6</span><span class="p">;</span>
<span class="nv">$body-bg</span><span class="p">:</span> <span class="mh">#121212</span><span class="p">;</span>
<span class="nv">$font-family-base</span><span class="p">:</span> <span class="s1">'Segoe UI'</span><span class="o">,</span> <span class="nb">sans-serif</span><span class="p">;</span>

<span class="k">@import</span> <span class="s2">"../node_modules/bootstrap/scss/bootstrap"</span><span class="p">;</span></code></pre></figure>

<p>Then in package.json, <code class="language-plaintext highlighter-rouge">sass</code> is included as a dependency, and there is an npm script to compile the Bootstrap SCSS and place the compiled Bootstrap CSS in <code class="language-plaintext highlighter-rouge">wwwroot/css</code>:</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"lilgobguides"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Old School Runescape Guides For Ultimate Irons"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build-bootstrap-css"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sass scss/custom-bootstrap.scss wwwroot/css/bootstrap-custom.css"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-win"</span><span class="p">:</span><span class="w"> </span><span class="s2">"copy </span><span class="se">\"</span><span class="s2">node_modules</span><span class="se">\\</span><span class="s2">bootstrap</span><span class="se">\\</span><span class="s2">dist</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2"> </span><span class="se">\"</span><span class="s2">wwwroot</span><span class="se">\\</span><span class="s2">js</span><span class="se">\\</span><span class="s2">bootstrap.bundle.min.js</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build-bootstrap-js-linux"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cp node_modules/bootstrap/dist/js/bootstrap.bundle.min.js wwwroot/js/bootstrap.bundle.min.js"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"keywords"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
  </span><span class="nl">"author"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"license"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ISC"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"bootstrap"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^5.3.6"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"sass"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.89.0"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span></code></pre></figure>

<p>In the above there are also two scripts for copying the Bootstrap JS to <code class="language-plaintext highlighter-rouge">wwwroot/js</code>; one is for my Windows machine that I use to develop the site, and the other is for the GitHub Actions runner which is Linux.</p>

<h2 id="github-action">GitHub action</h2>

<p>With those npm scripts, the GitHub action is:</p>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">deploy-lilgobguides</span>

<span class="na">on</span><span class="pi">:</span>
    <span class="na">workflow_dispatch</span><span class="pi">:</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build-and-deploy</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup .NET</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-dotnet@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">dotnet-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">8.0.x'</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Node.js</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">18'</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install npm packages</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">.</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm install</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build Bootstrap CSS</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">.</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm run build-bootstrap-css</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build Bootstrap JS</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">.</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm run build-bootstrap-js-linux</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Publish self-contained executable</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">dotnet publish . -r linux-arm64 --self-contained </span><span class="no">true</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">SCP dotnet executable to VM</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">appleboy/scp-action@v0.1.7</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">host</span><span class="pi">:</span> <span class="s">${{ secrets.HOST }}</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">${{ secrets.USERNAME }}</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s">${{ secrets.PRIVATE_SSH_KEY }}</span>
          <span class="na">source</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./bin/Release/net8.0/linux-arm64/publish"</span>
          <span class="na">target</span><span class="pi">:</span> <span class="s">${{ secrets.lilgobguides_DEPLOY_DIRECTORY }}</span></code></pre></figure>

<p>The above checks out the repo, installs .NET, Node, and the npm packages, runs those scripts in <code class="language-plaintext highlighter-rouge">package.json</code> to compile/copy the Bootstrap assets to <code class="language-plaintext highlighter-rouge">wwwroot</code>, compiles the .NET project (now including those frontend assets) into a self-contained assembly targeting linux-arm64 and SCPs the resultant artifact to the host machine with the <code class="language-plaintext highlighter-rouge">appleboy/scp-action</code>.</p>

<h2 id="systemd-service">systemd service</h2>

<p>On the Azure VM there is a systemd service file for the app <code class="language-plaintext highlighter-rouge">/etc/systemd/system/lilgobguides.service</code>:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="o">[</span>Unit]
<span class="nv">Description</span><span class="o">=</span>lilgobguides

<span class="o">[</span>Service]
<span class="nv">Type</span><span class="o">=</span>simple
<span class="nv">WorkingDirectory</span><span class="o">=</span>/path_to/lilgobguides/bin/Release/net8.0/linux-arm64/publish
<span class="nv">User</span><span class="o">=</span>user-which-app-runs-as
<span class="nv">ExecStart</span><span class="o">=</span>/path_to/lilgobguides/bin/Release/net8.0/linux-arm64/publish/lilgobguides
<span class="nv">Environment</span><span class="o">=</span><span class="nv">ASPNETCORE_URLS</span><span class="o">=</span><span class="s2">"http://0.0.0.0:5002"</span>
<span class="nv">Environment</span><span class="o">=</span><span class="nv">lilgobguides_DATABASE_PATH</span><span class="o">=</span><span class="s2">"Data Source=/path_to/lilgobguides.db"</span>

<span class="o">[</span>Install]
<span class="nv">WantedBy</span><span class="o">=</span>multi-user.target</code></pre></figure>

<p>This allows starting up the app with <code class="language-plaintext highlighter-rouge">sudo service lilgobguides start</code> or <code class="language-plaintext highlighter-rouge">sudo service lilgobguides restart</code>; if changes are made to that file it is necessary to run <code class="language-plaintext highlighter-rouge">sudo systemctl daemon-reload</code> first; and <code class="language-plaintext highlighter-rouge">sudo service lilgobguides status</code> shows the status and if that shows the app has exited <code class="language-plaintext highlighter-rouge">sudo journalctl -u lilgobguides.service -n 50</code> can be useful to see the problem.</p>

<p><code class="language-plaintext highlighter-rouge">User=</code> sets the user that the process will start under; as the <code class="language-plaintext highlighter-rouge">Program.cs</code> has the SQLite database initially created by migrations, that affects the file ownership of that database.</p>

<p>Specifying the working directory avoids path-related errors.</p>

<p><code class="language-plaintext highlighter-rouge">Environment=ASPNETCORE_URLS="http://0.0.0.0:5002"</code> sets an environment variable that has the effect of the running app listening to HTTP on that port. If this environment variable was set to two origins, those would be for HTTP and HTTPS, and my experience with this work was that with two origins set, the app would listen on the HTTPS one. In my case I am using Apache to serve the site over port 443 with HTTPS and acting as a reverse proxy to the app server, so it’s fine for it to use HTTP.</p>

<p>The above also sets an environment variable for the path to the SQLite database; in Program.cs it is used here:</p>

<figure class="highlight"><pre><code class="language-c#" data-lang="c#"><span class="kt">string</span> <span class="n">dbConnection</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">dbConnection</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"DefaultConnection"</span><span class="p">)</span>
    <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">"No database connection string"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsProduction</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">dbConnection</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"lilgobguides_DATABASE_PATH"</span><span class="p">]</span>
    <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidOperationException</span><span class="p">(</span><span class="s">"Is there environment variable for database path?"</span><span class="p">);</span>
<span class="p">}</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">AppDbContext</span><span class="p">&gt;(</span><span class="n">options</span> <span class="p">=&gt;</span>
        <span class="n">options</span><span class="p">.</span><span class="nf">UseSqlite</span><span class="p">(</span><span class="n">dbConnection</span><span class="p">));</span></code></pre></figure>

<p>If the <code class="language-plaintext highlighter-rouge">"DefaultConnection"</code> value in <code class="language-plaintext highlighter-rouge">appsettings.json</code> were used as in development, the database would be created in the deployment folder with the .NET assemblies.</p>

<h2 id="apache-configuration">Apache configuration</h2>

<p>The Apache <code class="language-plaintext highlighter-rouge">.conf</code> file for the site:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">&lt;VirtualHost <span class="k">*</span>:443&gt;
        ServerName lilgobslayerguides.net
        ProxyPreserveHost On
        ProxyPass / http://localhost:5002/
        ProxyPassReverse / http://localhost:5002/
        ErrorLog <span class="k">${</span><span class="nv">APACHE_LOG_DIR</span><span class="k">}</span>/app-error.log
        CustomLog <span class="k">${</span><span class="nv">APACHE_LOG_DIR</span><span class="k">}</span>/app-access.log common

        SSLEngine On
        SSLCertificateFile /path_to/lilgobslayerguides_net.crt
        SSLCertificateKeyFile /path_to/lilgobslayerguides.net.key
        SSLCertificateChainFile /path_to/lilgobslayerguides_net.ca-bundle

        SSLProtocol <span class="nt">-all</span> +TLSv1.2
&lt;/VirtualHost&gt;</code></pre></figure>

<p>With Apache virtual hosts, the VM is able to serve multiple websites over the same port and IP address (port 443 for HTTPS, and it is the same IP address since it is the same VM) by matching the <code class="language-plaintext highlighter-rouge">ServerName</code> with the request; this shows how Apache is used as a reverse proxy and conventional names for the TLS cert files.</p>]]></content><author><name></name></author><category term="programming" /><category term="c#" /><category term="razor pages" /><summary type="html"><![CDATA[Yesterday and today I did some work on CI/CD for my Old School Runescape blog/guides website lilgobguides, which I redeveloped recently as a Razor pages app with Bootstrap and an SQLite database. This post just notes some details on what I did for that.]]></summary></entry></feed>