rss expert Log in

Anyone can read here. Sign in to reply, save and follow.

From elsewhere

Pablo Murad pablomurad.com

a new project is on the way

Two years ago, I worked as a director at Genmap, in a kind of interim position that combined the roles of company director and project director. It was one of the best experiences I've ever had. As someone who programs as a hobby, I learned a lot from the company's professionals.

And I learned. I learned a lot about crawlers.

After all, Genmap is a Brazilian company that specializes in sitemaps.

That time at the company was truly incredible. We built so many engines and bots that I lost count, with new features every day, all designed to find as many links as possible on a website (after all, that's what a sitemap does).

I learned about crawl depth, crawling, concurrency, and other incredible related topics that I'll talk about another time.

The Idea

Last night, I called a former coworker. We stayed on a video call for almost an hour because I couldn't get an idea out of my head.

The idea was to build a crawler for the IndieWeb.

No admin panel, dashboard, or anything else. I would use only one or two seed URLs as starting points, then let it crawl as much as it could handle.

My questions for my former coworker were things like:

•     What's the logic behind search engines?

•     How much hardware would a given task require?

•     What about bot blocking?

•     What about robots.txt?

And many others.

There really were many more questions. I needed to cut the conversation short before it ran all night.

Reverse Scope

Well, as with any other project, I start by writing down what I don't want it to be. I write down everything my project should stay away from. What remains is what it should be, what it could be, or what it could grow into.

It's more or less like saying, "This water won't be coffee..." You can understand that as, "Then it could be any kind of juice."

This way of thinking has always kept me optimistic about everything I've done in life, because if I knew where not to go, every other path was an adventure and any result was satisfying.

I had made a decision. I needed to find experienced developers to build an engine for the IndieWeb. That would take money, time, and attention. And, of course, every bit of help I could get.

From 10:00 p.m. until a little after 11:00, I thought about and wrote down everything my project shouldn't be. And look, I had the perfect reverse scope, hehe.

Finding the People

I joined my friends in the Portal IDEA channel on our private IRC network and dropped the bomb. In less than an hour, I had about four people interested. They seemed genuinely interested because we were doing it for the love of it and to learn.

I offered to cover the infrastructure. They would take care of the code.

After a few very complicated weeks in my life, I was actually pretty excited to help make this project happen.

Now We Wait

Now we'll wait a few months and see what happens...

Read more

Pablo Murad pablomurad.com

The Wayseer Manifesto

Attention, all you rule breakers, misfits, and troublemakers. All you free spirits and pioneers, all you visionaries and nonconformists. Everything the establishment told you was wrong with you is exactly what is right with you. You see things other people don't. You're wired to change the world. Unlike nine out of ten people, your mind can't be repressed, and that threatens authority. You were born to be a revolutionary. You can't stand rules because, in your heart, you know there is a better way. You have strengths the establishment considers dangerous, and it wants them eliminated. So, your entire life, you've been told that your strengths were weaknesses.

Jobs

I'm telling you the opposite.

Your impulsiveness is a gift. Impulses are your key to the miraculous. Your distractibility is evidence of your inspired creativity. Your mood swings follow the natural pulse of life, giving you unstoppable energy when you're high, then deep and moving insight when you're low. Labeling you with a disorder is society's newest way of denying its own sickness by pointing a finger at you. Your addictive personality is a symptom of your vast, underused capacity for heroic creative expression and spiritual connection.

Your complete lack of inhibition. Your wide-eyed idealism. Your utterly open mind.

Has no one ever told you? These are the strengths shared by the greatest pioneers and visionaries. Innovators, revolutionaries, procrastinators, drama queens, social activists, daydreamers, nonconformists, philosophers, outcasts, people in suits and ties, football stars and sex addicts, celebrities with ADD, novelty-seeking alcoholics, first responders, prophets and saints, mystics and agents of change.

We're all the same, you know. Because we're all touched by the wave.

We're all the same, you know. Because we're all drawn to the flame.


You know in your heart that there is a natural order. Something more sovereign than any rule or law made by human beings could ever express.

That natural order is called the Way.

The Way is the eternal substrate of the cosmos. It guides the joyful currents of time and space. Some know it as the will of God, divine providence, the Holy Spirit, the implicate order, the Tao, reverse entropy, the life force. For now, let's simply call it the Way.

The Way is reflected in you as the source of your inspiration, your passions, your wisdom, your enthusiasm, your intuition, your spiritual fire, your love. The Way takes the chaos of the universe and breathes life into it, giving it divine order. When expressed through the mind, the Way is genius. When perceived by the eyes, it is beauty. When felt through the senses, it is grace. When allowed into the heart, it is love.


Most people can't perceive the Way directly.

But then there are the wayseers, the keepers of the flame.

Wayseers have an inexplicable gift for simply knowing the Way. They feel it in their own being. They can't tell you why or how they arrived at the right answer. They just know it at their core. They can't show you how they got there, so don't ask. Their minds simply resonate with the Way. When the Way is present, so are they. While others are blind to it, and society begs you to ignore it, the Way stirs inside you.

Neurological repression blocks most people's awareness of the Way. Your prefrontal cortex, the Gestapo of the brain, censors every thought and impulse rising from the unconscious. Nothing that violates its social programming is allowed through.

But your mind is different.

Your mind was thrown wide open to the Way. Through some miraculous genetic trait, some psychotropic chemical, or perhaps even the will of your own soul, your brain's reward pathways were hijacked. Dopamine was recruited to overthrow the fascist dictatorship of your prefrontal cortex. Now your brain is free from repression. Your mind is free from censorship. Your consciousness is exposed to the turbulent seas of the unconscious. Through that open door, divine light shines into your awareness and shows you the Way.

That is what makes you a wayseer.


Ninety percent of human civilization is populated by people whose brains are closed to the Way. Their minds are programmed to enforce the social doctrine installed in them from birth. Unlike you, they can't break through that programming because they haven't yet experienced the necessary revolution of the mind.

Programmed people take social institutions and rules very seriously. Society is packed with games designed to keep people's minds occupied so they won't revolt. These games often create unhealthy fixations on peculiar protocols, power structures, taboos, and domination. They are all subtle forms of human slavery.

This particular kind of madness is tolerated by the masses.

It is demanded.

The programmed believe in the rules so fiercely that they become willing to destroy anyone who breaks them. Wayseers are the ones who expose them.

Because their minds are free to reject social programming, wayseers can see these institutions for what they are: imaginary games. Wayseers comfort the disturbed and disturb the comfortable. Helping those who are lost inside these games, even when they refuse to help themselves, is the calling of many wayseers.

Wayseers remain in contact with the original source of reality. That is why they can disrupt social conventions and even governments, forcing humanity back into alignment with the Way. They are an ancient lineage, a kind of priesthood, bearers of the flame, the ones who know.

There must always be wayseers to reform the dizzying, psychotic machinery of society, those gigantic and mindless hamster wheels that blot out the clear blue sky while keeping humanity chained inside a darkened cage.

And so wayseers are called to shine a light on society's madness, continually bringing the timeless, transcendent spirit of truth back to life.

Wayseers reveal that divine truth by giving themselves to the birth of something creative or disruptive: art and philosophy, innovations that shake industries, revolutions for democracy, blows against hypocrisy, solidarity movements, changes that leave a legacy, rebellions against policy, technology filled with spirit, moments of clarity, things that challenge barbarism, milestones of sincerity, monumental acts of charity.

We're all the same, you know, because we're all touched by the Way.

We're all the same, you know, because we're all drawn to the Way.


This is your calling, wayseer.

You've found your tribe.

Welcome home.

Read more

Pablo Murad pablomurad.com

the dawn of rss feeds

The web had a simple promise: any site could publish a feed, anyone could subscribe, and nobody in the middle got to decide what you saw. RSS made that work for twenty years. What it never had was the conversation. You read someone's post in your reader, but replying means going to that person's platform. Suddenly you're back inside the walled garden RSS was meant to avoid.

Social networks solved the conversation and threw away the rest. The feed belongs to someone else. An algorithm decides the order. Your identity is a username the platform lends you and can take back. Leaving one network means starting from nothing on another, because what you wrote and the people who followed you stayed behind.

RSS Expert wants both things at once: the openness of RSS and the conversation of social networks, without an owner standing in the middle. You read feeds as usual. When you reply, that reply is published in your own feed, with a link to the original post. Nothing is written on somebody else's server. The other side discovers the reply because it's in the feed and because the system sends a Webmention.

The conversation happens, but it remains yours, at your address.

What it is

One static binary, one SQLite file, one container.

The only JavaScript is a twenty-line island that reveals a "new post" notice. Every page works without it. There's no CSS framework, no build step for the assets, and no separate database server to bring up. That's deliberate. One person should be able to run the whole thing on a small server and understand all of it.

These ideas hold up everything else.

Provenance is first-class information

Most systems store "this post exists." This one stores "this post was seen in this form, from this source, at this time, and I chose this version for this reason."

A post arriving through two paths isn't overwritten. Both observations remain recorded, and convergence chooses a winner deterministically while showing why. The timeline separates what was written here from what arrived from elsewhere, and it never forgets where something came from.

The Where it went screen shows every feed carrying one of your posts and every notification that was sent, including the ones that failed.

Your domain is your identity

You prove that a site belongs to you using rel=me and an h-card verified in both directions. It becomes your name in the system.

It isn't an account somebody lends you.

Federation isn't optional

It's the point. Being open and alone isn't very useful. The system speaks the protocols other open networks already speak instead of inventing its own.

Who it talks to

On the RSS and IndieWeb side, everything works without anybody else having to run this same program:

  • It reads RSS 2.0, Atom, JSON Feed, h-feed, and OPML.
  • It publishes a per-user feed, a site-wide feed, and a reply feed for each post. Any reader can subscribe.
  • WebSub and rssCloud work in both directions. As a subscriber, RSS Expert discovers a feed's hub or cloud and receives posts as soon as they're published. As a publisher, it is its own hub and its own cloud, with no third-party service in between.
  • Webmention tells the other side about a reply, and Micropub lets another application publish through it.

On the fediverse side, enabled with RSS_EXPERT_ACTIVITYPUB:

  • @yourhandle@rss.expert becomes an address anyone on Mastodon can find and follow. When you publish, the post appears in their timeline.
  • Conversation crosses in both directions. A reply written on Mastodon joins the thread here, and a reply written here reaches the remote author's inbox.
  • Editing sends an Update. Removing a post sends a Delete. Likes and boosts are counted. HTTP signatures support both the newer standard, RFC 9421, and the older draft Mastodon still uses. The system remembers which one each server accepts.

What it's for, and who it's for

It's for someone who wants to read the open web in one place, publish without depending on a platform, and talk to people on other networks while keeping their identity, history, and provenance on their own side.

A blog that is also a reader and a fediverse account, without becoming three different services.

It isn't for everyone. If you only want to post something and be seen by a lot of people quickly, a large network will serve you better. RSS Expert trades reach for ownership. Fewer people will find you for free, but what's yours stays yours, and nobody in the middle decides what happens to it.

Why it's built this way

Every major choice has a recorded reason in docs/decisions/, in the ADRs. These are the big ones:

  • Small on purpose. A static binary without cgo, a FROM scratch image, and SQLite instead of a database server. It can run on almost nothing, and there are fewer things waiting to break.
  • No outside library for the sensitive parts. ActivityPub HTTP signatures were written by hand and tested against known vectors instead of inheriting an abandoned dependency. TOTP follows the same discipline.
  • Security defaults to the safe choice. Every outbound request passes through an SSRF guard, checked after DNS resolution and before connecting, on every hop. Upload types come from their bytes, files are decoded before storage, and metadata is stripped. When a setting is a security decision, its default is the safe one.
  • It can't look AI-made. The code reads like a person wrote it. The visual design follows real reference pages, with no purple gradient, glassmorphism, or emoji in the interface. Every color and size comes from one token file.

Where the ideas came from

The implementation is independent. No code was copied from another project.

But the interoperability knowledge that makes it possible came from other people's work: RSS, OPML, rssCloud, and Dave Winer's Textcasting; Ricardo Mendes's RSC, which proved the idea could work and gave this project a map; Micropub, Webmention, and the IndieWeb microformats; and ActivityPub and ActivityStreams from the W3C.

Where it is

rss.expert, version 0.0.1, in testing.

The federation loop has been proven between two instances I control and by a large test suite. One check remains, and no automated test can cover it: a real mastodon.social account following @pablo@rss.expert, with the conversation actually crossing both ways.

Until that happens, "it works" describes the code.

It doesn't yet describe the world outside.

Read more

Pablo Murad pablomurad.com

Your Recovery Codes Need a Better Home

Imagine a very bad Tuesday.

Your phone is dead. Your laptop was stolen. You try to open your email on another computer, but it asks for a code from the authenticator app that was on the phone. Fine, you think, the recovery codes are in the password manager. Then the password manager asks for the same missing second factor.

The backup file was on the laptop.

Its second copy was in cloud storage connected to the email account you can't open.

Nobody hacked you. You're still locked out.

This is the stupid side of good security. We spend time protecting accounts from strangers and then build a recovery process that depends on every device and service working normally. Of course it looks safe on an ordinary day. Recovery codes exist for the day that isn't ordinary.

The question seems simple: where should I keep these codes?

The honest answer is more annoying. There isn't one perfect place. You need a few places that fail differently, and the arrangement has to remain understandable when you're tired, worried, using an unfamiliar computer, or explaining it to someone who doesn't care about your beautiful security system.

That last part matters.

Recovery material is a key

A recovery code isn't a harmless note. Whoever has it may have a way into the account, sometimes with only one other piece of information. Treating it like a receipt in the Downloads folder is strange when you think about what it can do.

Services also use similar names for different things. Google gives you a set of single-use backup codes. Microsoft uses a 25-digit recovery code and replaces the old one when a new code is generated. Apple's recovery key is a 28-character secret with much heavier consequences. If you enable it and later lose the key along with access to a trusted device, Apple says you can be locked out permanently.

Then there are TOTP seeds, Emergency Kits, passkeys, spare security keys, and provider-specific recovery contacts. They don't behave in the same way. A one-time code can be crossed out after use. A TOTP seed can reproduce authenticator codes again and again. A hardware key sitting in a drawer is useless unless it was already registered with the account.

So the first job is identification. Write down what the item actually is, which account it belongs to, when it was created, and whether using it invalidates anything else.

Plain names help. Mysteries don't.

Look for the circle

Before buying a safe or making an encrypted container, test the dependency.

If the account disappears from your life for one hour, can you still reach its recovery material without using that account?

Google codes stored only in Google Drive fail this test. An Apple recovery key kept only in iCloud Notes fails it too. The Emergency Kit for a password manager can't live exclusively inside that password manager. An encrypted file isn't very impressive when its only passphrase is stored inside the file.

These arrangements can survive for years without showing the problem. That's why they're dangerous. The trap becomes visible at the exact moment you need the exit.

Apple tells users to print or write down the recovery key, keep it somewhere safe, and consider more than one location. The company specifically warns against leaving the only copy in Apple Passwords, iCloud Photos, Notes, or iCloud Drive. NIST's current authentication guidance also describes saved recovery codes as secrets intended to be kept offline and stored securely.

Paper, apparently, is still alive.

The paper envelope is boring and excellent

Print the critical codes or write them very clearly. Put them in an opaque envelope. Add the date, seal it, sign across the seal, and store it with documents that already deserve protection.

That signature won't stop a determined person. It can tell you that the envelope was opened, which is useful. If the seal looks wrong, enter the accounts through a normal method and regenerate the exposed codes.

The paper inside should be understandable without a tutorial. It needs the service name, account identifier, official recovery address, generation date, number of available codes, primary MFA method, and the location of another valid route. Avoid screenshots when plain text will do. Interfaces age. Text survives.

A small notice on the envelope is enough:

EMERGENCY DIGITAL ACCESS
Prepared: YYYY-MM-DD
Review after: YYYY-MM-DD
If this seal is broken unexpectedly, replace every code.

Don't write every secret you own on the same unprotected page. A password, its TOTP seed, and the recovery codes together can become a complete account takeover kit. Some family or succession plans may need all the pieces, but then the physical protection and the person holding the package become far more serious decisions.

And check the box itself. A thin metal box isn't magically fireproof because a store put the word “safe” on it. Look at its real document rating, including time, temperature, and water protection.

Paper can burn. It can get wet, become outdated, or be photographed. This is why the envelope is a fallback, not the entire plan.

The encrypted capsule

Alex Chan wrote about keeping his recovery material in a small, encrypted disk image, with offsite backups and a paper copy planned for a fire safe. The useful part of that solution is that the encrypted container can travel while the files inside remain simple.

The container might be a protected disk image, an encrypted virtual disk, or a small VeraCrypt volume. Inside it, use files that should still open years from now: Markdown, plain text, a self-contained HTML page, perhaps the original PDF supplied by a service.

No custom app. No database that requires an abandoned framework. No personal cipher based on a poem you expect your future self to remember.

Keep the container small and copy it to more than one medium. One copy can stay on the computer, another on disconnected storage, and an encrypted copy can live outside the house. Cloud storage is acceptable for the already encrypted file, provided the passphrase doesn't depend entirely on that same cloud account.

The passphrase needs its own exit. A sealed copy at home can work. A password manager plus an independent physical record can work. Hiding four characters in a photograph from 2009 is how a practical plan turns into folklore.

An encrypted USB drive is still a USB drive. It can fail quietly, disappear in a pocket, or sit unread for years. The encryption protects the contents from whoever finds it. It doesn't make the device immortal.

The password manager problem

Keeping ordinary recovery codes in a password manager is convenient. Search works. Updates are easy. It is vastly better than forgetting them in Downloads.

The problem begins when the manager contains the only recovery route for itself, the primary email account, the phone ecosystem, and every other account capable of resetting the rest. What appears to be a collection of separate protections can collapse as one object.

Password managers with emergency features can help. Bitwarden Emergency Access lets a designated contact request access after a waiting period. The 1Password Emergency Kit contains information needed to set up the account on a new device and is meant to be stored safely.

But a feature nobody configured is decoration.

The contact has to accept. They need to understand when access is allowed, where the instructions are, and what they should never send through chat or email. The password manager itself still needs a route that doesn't depend on opening the password manager.

The setup that makes sense

A three-location recovery plan with an encrypted capsule, a paper envelope in a home document box, and a separate offsite copy

For someone with personal accounts, domains, self-hosted services, and a reasonable tolerance for technical work, a layered setup is the sensible choice.

The working copy is the one available quickly. Ordinary account codes can stay in the password manager. Critical material can also live in the encrypted capsule on the computer, opened only when something needs to be read or changed.

The local physical copy is the sealed envelope in a safe or protected document box. It needs no internet connection, subscription, operating system, or healthy SSD. That's a surprisingly useful list of properties.

The offsite copy lives at another address. It may be another sealed envelope with a trusted person, or an encrypted container whose passphrase travels through a separate route. Another drawer in the same house doesn't count. Neither does a second disk permanently connected to the same machine.

Then add another authenticator where the service allows it. A spare security key is a cleaner first fallback than consuming a recovery code, but only when it was registered in advance. Yubico recommends enrolling the spare at the same time as the primary key and storing it somewhere safe and accessible.

This arrangement covers different failures without becoming a small religion. The digital copy handles routine device loss. Paper remains readable after computer trouble. The offsite copy survives the event that takes the whole house. A second authenticator may avoid the recovery process completely.

Neglect can still ruin it. An envelope full of invalid codes is a very organized way to remain locked out.

Start with the accounts that control the others

Primary email comes first. Then the password manager, phone or operating-system account, domain registrar, cloud storage, code repository, and infrastructure providers. Financial accounts follow their own official recovery rules and deserve the same attention.

These are root accounts. If one of them falls, several others may follow. Protecting a hundred minor logins while the primary email has one fragile recovery route is excellent filing and bad prioritization.

People who run servers have more roots than they think. The domain and DNS provider matter. So do the VPS panel, Tailscale or another administrative network, external backups, the source forge, transactional email, and any control panel that can reset access or destroy machines.

Don't store the only recovery vault on the server it is supposed to help recover. This sentence sounds obvious. Plenty of backup systems contain an equally obvious joke.

Recovery codes don't replace SSH key management, configuration backups, restore notes, or access procedures. Keep those systems connected in the inventory, but don't throw every secret into one convenient package.

A plain inventory is enough:

Service:
Account identifier:
Official recovery page:
Primary MFA method:
Registered alternate method:
Recovery material created:
Codes remaining:
Last checked:
Working copy:
Local physical copy:
Offsite copy:
Next review:
Notes:

The inventory can stay separate from the secrets. Its job is to tell you what exists and where, including which items were deliberately kept elsewhere.

Test while the door is still open

Don't begin with the account that controls your entire digital life.

Choose a low-risk service that provides several recovery codes. Confirm the password and another MFA method first. Open a private browser window, retrieve one code through the route you designed, use it, and mark it as consumed immediately. Then check every copy that contains the individual codes.

For a critical account, you can rehearse without entering a code. Pretend the phone and computer are gone. Can you locate the instructions? Can you obtain the container passphrase? Is the spare key actually registered? Does the official recovery page still exist?

GitHub warns that support can't restore access when two-factor credentials and every recovery method are gone. If no recovery option works, the account may be permanently lost. That's the sort of policy worth discovering during a test, with a normal session still open.

Review the system after a move, a compromised computer, a broken seal, a changed custodian, or any provider change that replaces old codes. Google and Microsoft invalidate previous sets when new ones are generated, so keeping several generations “in case” creates confusion instead of safety.

An annual check is reasonable for the whole kit. A lighter check every few months makes sense for the root accounts. Weekly maintenance would turn this into a chore, and chores have a way of being abandoned.

The first version doesn't need to be elaborate. Identify the accounts that can recover the others. Generate fresh codes through their official pages. Print and date them. Put one protected copy at home and another at a genuinely different location. Register the spare factor now, while you can still sign in normally.

Then test one unimportant account.

That's enough work for one afternoon, and the result should still make sense on the next bad Tuesday.

Sources

Read more

Pablo Murad pablomurad.com

RSS Expert

Well, I didn’t know whether something like this already existed. But a while ago, I had an idea: what if I could use RSS feeds to communicate? Everyone knows RSS feeds are one of the basic ways we stay updated, get notifications, and keep ideas moving. So I don’t think I need to explain what they are here.

But I do want to make a few observations. I still remember the first time I heard about RSS: it was around the same time I joined Reddit, on the day Aaron Swartz died. I remember seeing the news, although I hadn’t yet looked very deeply into his work.

People were mistakenly saying that Aaron was the creator of Reddit, and that was what drew me there, even though it’s well known that Huffman and Ohanian were its founders. Still, that doesn’t change the fact that he was one of Reddit’s earliest developers and is often treated as a co-founder. It’s also worth noting that he wasn’t involved in the project’s original idea or conception.

In any case, I started reading about Swartz and found someone with very clear goals and a concrete vision of an open, free internet. Among the works he co-authored, one in particular caught my attention: RSS, specifically RSS 1.0, whose working group he joined when he was still very young. And from that moment, tied to the life and death of Aaron Swartz, my small obsession with RSS feeds began.

In a way, almost everything around social networks follows principles that RSS feeds had already established: scrolling, getting notified, receiving updates in real time. Think about it: Facebook and Instagram are, in some sense, megalomaniacal RSS feeds. Someone updates a status; you, through the app, have the aggregator; then you see it and interact with it—just on a much larger scale.

As I’ve said before in other posts, the fediverse and the pubs became objects of fascination for me in mid-2025, when I spent most of my free time studying this whole development—its nuances, interpretations, and connections. In fact, it’s still a fascination of mine, and it remains incredibly captivating: decentralization as a form of expression.

One night a few months ago, while I was drinking coffee in the lab, a question came to me: what if I ran an experiment to make RSS more communicative?

I didn’t want this experiment to become just one more thing among the hundreds of RSS-related projects sitting in my GitHub. I wanted something relevant. Something more solid.

So I started researching, and after several analyses, studies, completely failed implementations, and plans that were far too abstract, something finally took shape in my head:

“I’m going to build a feed reader and publisher that lets people talk, with the conversation happening through RSS.”

Then I imagined three things that would normally require three different programs:

1 – Reading what other people have written (a feed reader)

2 – Writing and being read by the people who follow me (a blog)

3 – Replying to someone and having that reply reach them (a social network)

 From that point on, I knew what I had to do: bring the three together. I set out to build a system that could do all three things in the same format: static XML served over HTTP.

No API would be needed to take part. If a site published an <item> with a <source:inReplyTo> pointing to one of its posts, that would count as a reply, no matter what software was being used.

Prototype of RSS Expert

 And why does that matter?

In a conventional social network, your account belongs to the company. Here, your identity is your domain: you prove that yourwebsite.com is yours, and that’s what appears next to your name. If you leave this instance someday, you take your domain and your readers with you. They follow a feed URL, not a profile.

Well, I won’t drag this out too much. This is just a glimpse of what I’ve been working on. For now, I think that’s enough...

Read more

Pablo Murad pablomurad.com

Building text2.site

I opened an article a few days ago. It had maybe 800 words worth reading.

The page downloaded 4 MB.

There was a cookie banner, a newsletter modal waiting for my mouse to move toward the top of the screen, a fixed bar above, another fixed bar below, a "you may also like" box wedged into the middle of a paragraph, three social media embeds, and a video that started playing without being invited. The 800 words were still there, technically. I just had to excavate them.

That was the entire origin story. No revelation. No grand theory about saving the web.

I was annoyed.

So I built text2.site, a small service that does one thing: you paste a URL and it gives you the text. No account, no database, no cookies, and nothing stored. Just a .txt file that opens everywhere and will probably continue opening thirty years from now.

Why plain text

Plain text may be the most underestimated format we have.

It goes into a note and remains readable. It works in an e-book reader. A screen reader can move through it without tripping over decorative buttons. If the original website disappears, the file does not suddenly forget how to exist. It takes kilobytes instead of megabytes.

More importantly, plain text has no opinion about how you should consume it.

It does not demand a browser, a particular app, an account, JavaScript, or a relationship with an advertising company. It is simply the content, which feels almost radical now.

One file, no framework, no build step

The first decision was also the most important: use almost nothing.

No React. No Next.js. No bundler, build pipeline, or dist/ directory. The service is a Node server using the native http module, with handwritten HTML and CSS, and an npm start command that behaves the same way on my machine and on the VPS.

This is not nostalgia. It is arithmetic.

Every framework I added would become another thing to update, another surface that could break, and another layer between "I want to change this" and "I changed it." For a service with one job, the framework would cost more than it gave me.

The entire front end is three files. The server fits inside my head. I like software that can still fit inside the head of the person maintaining it.

Readability, and what happens when it gives up

At the center of the service is Mozilla Readability, the same engine behind Firefox Reader View. It identifies the article and throws away the surrounding machinery. It is mature, has zero dependencies, weighs about 200 KB, and is remarkably good at this specific job.

It also has a personality: when uncertain, it returns nothing.

That makes sense inside a browser. If Firefox is not confident that a page contains an article, it simply does not offer Reader View. You continue reading the normal page. A converter does not have that luxury. If my service says "I found nothing," the user does not get a second option. They leave empty-handed.

So I built a ladder underneath Readability. When the first extraction fails, text2.site tries the following:

  1. Unwrap <noscript> and run Readability again. Many modern pages are JavaScript shells with the real content sitting inside <noscript> for search engines.
  2. Look for articleBody in JSON-LD. It is not common, but checking it is almost free.
  3. Try <article>, <main>, and [role=main].
  4. As a last resort, take the entire <body> and aggressively remove navigation, headers, footers, and obvious noise.

One small detail cost me a real bug: <noscript> must only be unwrapped after the first attempt fails. If I unwrap it immediately, perfectly good pages sometimes acquire an "Enable JavaScript to continue" message right in the middle of the article.

The order matters. It often does.

The >>> 2 + 2 4 that made me rewrite everything

My first HTML-to-text converter was handwritten. A recursive walk(), around sixty lines, and it worked.

Then I converted the Python tutorial.

>>> 2 + 2 4 >>> 50 - 5*6 20 >>> 8 / 5 # division always
returns a floating-point number 1.6

That was supposed to be a code block. Every >>> should begin a new line, and every result should appear on the following line. My converter flattened the whole thing into one paragraph. The code became alphabet soup.

The problem was not a small bug. The problem was that I had underestimated the job.

Converting HTML to readable text looks easy until you try to do it properly. A <pre> block must preserve every meaningful space. Tables need aligned columns. Nested ordered lists can start at five and use Roman numerals. A quotation needs > on every wrapped line, not only the first one.

Each edge case is an afternoon of work followed by another afternoon of discovering what the first afternoon broke. Multiply that by ten and I would spend a week badly rebuilding what html-to-text has already handled well for years.

So I replaced my converter. The same Python example now comes out like this:

>>> 2 + 2
4
>>> 50 - 5*6
20

The RFC 9110 method table comes out aligned as well:

Method   Safe  Idempotent  Section
CONNECT  no    no          9.3.6
GET      yes   yes         9.3.1
POST     no    no          9.3.3

Before that, a table could become NameQtyApple10.

I am not exaggerating.

I kept a few formatting decisions of my own on top of the library. Headings use = and - underlines, the way plain-text README files have done for decades, and links become numbered references.

This part is a matter of taste, and I will defend mine.

A link contains two pieces of information: the words you read and the address they point to. Put the complete address inline and the paragraph becomes painful to read. Delete the address and you destroy information.

So text2.site turns a link into the words [4] and places the address in a reference list at the bottom. Academic writing solved this problem a century ago. There was no reason for me to invent a worse answer.

I added two rules that made a surprising difference.

First, the same URL always gets the same number. If an article cites one source five times, the reader sees [1] five times, not five separate references pretending to be different things.

Second, fragment links do not become references. A #section-3 link from a table of contents is useless once the page becomes a text file. Removing those links reduced the RFC 9110 output from 3,017 references to 1,166. Nearly two thousand lines of noise disappeared because of one rule.

The coffee that became caf?

This bug was embarrassing, which is probably why it became one of my favorites.

The service read the response bytes and called .toString('utf8'). Always. No questions asked.

Unfortunately, a meaningful part of the web still arrives as windows-1252 or ISO-8859-1: old institutional websites, city government pages, a CMS from 2011, a personal blog that never migrated. Words like café and ação arrived mangled.

The fix follows the order defined by HTML. Check the BOM first because it is unambiguous. Then inspect the HTTP header. Then look for <meta charset> inside the first 1,024 bytes. Use UTF-8 only as the final fallback.

There is one more practical trick. If a page insists that it is UTF-8 but the decoded result is full of replacement characters, I decode it again as windows-1252 and keep whichever version is less broken. Standards are useful. So is noticing when a website is lying.

The best part is that this required zero new dependencies. Node's own TextDecoder already supports shift_jis, gb18030, big5, koi8-r, and the rest. I only needed to confirm that the Alpine Node image ships with full ICU support. A thirty-second docker exec turned a vague doubt into a fact.

The day I realized I had built an open proxy

Everything looked harmless while the service ran on localhost. Then I prepared to put it on a public domain and the obvious finally became visible.

The /extract endpoint fetches any URL it receives. On a VPS, that means a stranger could ask my server to request http://127.0.0.1:5432, an internal network address, or the cloud metadata endpoint at 169.254.169.254, which can expose credentials on some providers.

It was not exactly a bug in one line of code. It was a consequence of deployment waiting patiently to bite me.

Now every hostname is resolved before the request. The service rejects loopback, private, link-local, CGNAT, and multicast ranges in both IPv4 and IPv6, including mapped forms such as ::ffff:10.0.0.1.

The detail that nearly escaped me was automatic redirection. With redirect: 'follow', the first URL can pass validation and the second hop can land directly on the metadata endpoint. Redirects are now followed manually, one at a time, with every destination checked again. The maximum is five.

One hole remains, and I document it because pretending otherwise would not close it. I resolve and validate the address, then fetch performs DNS resolution again. A record that changes between those two moments can slip through. That is DNS rebinding. Closing it correctly means pinning the connection to the IP address that was already verified.

It is on the list.

Lightweight is not an aesthetic. It is a measurement.

The project originally used jsdom. It is the standard DOM implementation for Node, and it works.

Then I measured it.

Measurement jsdom linkedom
Installed size 25 MB 5.6 MB
Load time 497 ms 108 ms
Memory while processing Wikipedia +17 MB +1 MB

I switched to linkedom.

The migration needed two fixes, both discovered by comparing the output side by side. linkedom exposes the contents of <template> as normal children, allowing boilerplate to leak into the text. It also has no base URL option, so relative links require a manually injected <base href>.

The final result was worth it. node_modules went from 25 MB to 6.3 MB, and somewhere along the way the project also gained a proper text renderer. Less weight and more capability at the same time.

That almost never happens.

A container and a quota of ten

text2.site runs in Docker. The image uses two stages, the process runs as an unprivileged user, the filesystem is read-only, all Linux capabilities are dropped with cap_drop: ALL, and dumb-init runs as PID 1 so the SIGTERM from docker stop actually reaches Node instead of waiting ten seconds for a kill.

The port binds to 127.0.0.1, nothing else. The container can only be reached through nginx. As far as the public internet is concerned, the container itself does not exist.

There is also a quota: ten successful conversions per IP every 24 hours. This is not the beginning of a premium plan. It is there so the server can remain cheap enough to stay open to everyone.

One rule matters to me: failure does not spend quota. Invalid URL, unavailable website, page with no extractable article - the attempt is returned. Only an actual result counts. Charging the user for my failure would be ugly.

The counters live in memory, and a restart clears them. That is a deliberate tradeoff, not an oversight. Deploying a database to preserve ten integers would cost more than the abuse it might prevent.

What I deliberately did not build

Pages rendered entirely in JavaScript do not work. Paywalls do not work. Content behind a login does not work.

I could support them with a headless browser. I will not.

A Chromium process can consume 300 MB of RAM per page and take seconds for each conversion. At that point I would have sacrificed exactly what I was trying to preserve: a small service that does one thing without dragging an operating system behind it.

When extraction fails, the website says so. A clear error is better than returning a navigation bar disguised as an article.

What building it taught me

Three things stayed with me.

First: most of the quality came from work performed before and after the main library. Readability is excellent, but it can only understand the DOM I give it. Removing hidden elements, excluding <template>, and stripping the little symbols that Sphinx and MDN attach to headings are preparation, not magic. That preparation changed the result.

Second: measurement is cheap and opinion is expensive. "jsdom is heavy" was only a feeling until I had 25 MB and 5.6 MB on the screen. Half an hour of benchmarking settled a question I could have argued about for a week.

Third: writing tests after the system already works feels like wasted time. It is not. The 42 tests I wrote at the end immediately found two problems: I was removing <script> elements before looking for JSON-LD inside them, and a short but legitimate page such as example.com had started being rejected. Both bugs would have reached production quietly.

text2.site has one screen, one text box, and one button.

It took much more work than it appears to contain.

That is the point.

text2.site - paste the URL, get the text.

Read more

Pablo Murad pablomurad.com

Coffin Joe

Brazil did not need a castle in Transylvania. It had a cemetery, a black top hat, a pair of impossible fingernails, and José Mojica Marins staring into the camera as if he had personally caught the audience trespassing.

That was enough.

Zé do Caixão — known abroad as Coffin Joe — is one of those characters who seem too complete to have been invented. He looks like folklore that has always existed: undertaker, blasphemer, philosopher, sadist, carnival barker, village tyrant. But he came from one man, one nightmare, and a kind of Brazilian cinema that had almost no money and absolutely no interest in asking permission.

What fascinates me about José Mojica Marins is not only that he created Brazil's greatest horror icon. It is that he built an entire mythology with whatever was available. Cheap sets. Amateur performers. Real animals. Television studios. Censored footage. His own face. His own nails. If an elegant production system did not exist for the films he wanted to make, he simply crawled under the system and kept filming from there.

A Boy Who Grew Up Inside a Cinema

Mojica was born in São Paulo on March 13, 1936. A Friday the 13th, naturally. If a publicist had invented that detail later, it would feel embarrassingly obvious. Reality was less subtle.

His father managed a neighborhood movie theater, and the family lived in the same building. Mojica spent his childhood close to the projection room, surrounded by reels, shadows, posters, and the mechanical rhythm of films passing through a projector. Cinema was not an occasional event for him. It was the architecture of the house.

At twelve, he received an 8mm camera and began making films with relatives, neighbors, and anyone willing to stand in front of the lens. He exhibited those early productions wherever he could and charged small admissions to recover the costs. By his late teens, he had founded an acting school and a small film company. The school supplied performers, trained technicians, and helped finance the next production. It was less an institution than a self-sustaining cinematic organism.

Before horror, he tried westerns, adventures, melodramas, and other popular genres. His first completed feature, A Sina do Aventureiro (The Adventurer's Fate, 1958), was a Brazilian western assembled with the same stubborn independence that would define the rest of his career. Mojica was not waiting to be invited into an established industry. He was manufacturing an industry around himself.

This matters because Zé do Caixão did not emerge from a comfortable studio searching for a marketable monster. He emerged from necessity. Mojica needed a character strong enough to survive poverty, censorship, bad equipment, skeptical actors, and audiences who had never seen a Brazilian horror film before.

So he made someone impossible to ignore.

The Nightmare That Became Zé do Caixão

Mojica said the character arrived in a nightmare in 1963. A dark figure dragged him from his bed and carried him toward a grave. Inside the grave, Mojica saw his own body waiting in a coffin. He woke up terrified and began developing the story that became À Meia-Noite Levarei Sua AlmaAt Midnight I'll Take Your Soul.

The dream explains the visual shell, but the character became much stranger than a simple supernatural undertaker. Zé is not Dracula, a ghost, or a demon. He is a human being who has decided that conventional morality is a weakness. He despises religion, mocks superstition, terrorizes his neighbors, and believes that the only form of immortality is “the continuation of blood”: producing a perfect son with the perfect woman.

Naturally, everyone around him becomes disposable.

Poster for At Midnight I'll Take Your Soul

Poster for At Midnight I'll Take Your Soul. Artwork © its respective rights holders, remotely displayed for identification and editorial commentary.

Released in 1964, At Midnight I'll Take Your Soul is widely regarded as the first Brazilian horror feature. It is raw, theatrical, angry, and much more inventive than its limited resources should allow. The film does not politely introduce Zé. He addresses the audience, challenges God, eats meat on Good Friday while a religious procession passes, assaults people who irritate him, and murders anyone obstructing his search for an heir.

There is something almost punk about the film, although it predates punk by more than a decade. It attacks social and religious authority, but it does not replace them with a clean political program. Zé's rebellion is selfish, violent, and grotesque. He is not a misunderstood hero. He is the nightmare produced when absolute individualism loses the last trace of compassion.

And yet he is magnetic.

That is Mojica's trick. Zé is horrible, but never dull. He enters a room and changes its temperature. The hat makes him taller. The cape gives him the silhouette of a nineteenth-century villain who somehow wandered into a poor Brazilian town. The beard, rings, and fingernails push him toward caricature, but Mojica's eyes pull him back into danger. He does not merely look at other characters. He seems to accuse them of being alive incorrectly.

José Mojica Marins as Zé do Caixão in At Midnight I'll Take Your Soul

José Mojica Marins as Zé do Caixão in At Midnight I'll Take Your Soul. Film still hosted by the British Film Institute; image © its respective rights holders.

The Most Brazilian Thing About Him

Calling the character “Brazilian Dracula” is convenient, but it misses what makes him special. Zé has no aristocratic castle and no ancient European bloodline. He is a local undertaker. He lives among ordinary people, argues with villagers, humiliates the faithful, and uses the daily intimacy of a small community as a weapon.

The horror is not visiting from somewhere else. It already owns a business in town.

Mojica borrowed the visual confidence of classic horror, but he filled it with Brazilian tensions: Catholic ritual, popular superstition, poverty, social hierarchy, masculine arrogance, and the constant friction between official respectability and the violence underneath it. The films feel handmade because they were handmade, but that roughness is not merely a technical deficiency. It gives them texture. Their world seems dirty enough to leave residue on the viewer.

The first film was a commercial success despite financial trouble and even ridicule from people around the production. Zé quickly escaped the screen. He appeared in comic books, records, television programs, public events, and eventually advertising. Mojica and the character became so closely fused that many Brazilians simply called the director Zé do Caixão.

It is one of the strangest victories in cinema: the monster ate the name of his creator, and the creator seemed delighted to keep feeding him.

Hell Suddenly Has Color

Mojica continued the story with Esta Noite Encarnarei no Teu CadáverThis Night I'll Possess Your Corpse — released in 1967. Zé survives the punishment at the end of the first film and resumes his search for the woman capable of bearing his “superior” child. His methods become more elaborate and more cruel. Women are abducted and subjected to tests involving snakes and spiders. The philosophical speeches grow larger. So does the madness.

Poster for This Night I'll Possess Your Corpse

Poster for This Night I'll Possess Your Corpse. Artwork © its respective rights holders, remotely displayed for identification and editorial commentary.

The film is mostly black and white, but its famous vision of Hell erupts into color. It is a beautiful decision because the color does not make the sequence more realistic. It makes it less stable. Bodies twist among crude, painted landscapes and impossible shapes. The limited production values stop being a weakness and become the logic of a fever dream.

Mojica often used real animals. The spiders, snakes, rats, and cockroaches were not polite effects added later. In a 2009 interview, he explained that he would submit himself to the animals during rehearsals before asking actors to do the same. This does not make the working conditions easy to defend by modern standards, but it complicates the popular image of Mojica standing safely behind the camera while terrorizing everyone else. He wanted the ordeal to be physical, and he put his own body into it.

The censorship was just as real. Mojica worked through the Brazilian military dictatorship, when blasphemy, sexuality, drugs, and attacks on moral authority attracted official attention. The ending of This Night I'll Possess Your Corpse was forced toward a religious concession that contradicted Zé's philosophy. Decades later, Mojica described Embodiment of Evil as a chance to let the character finally stand against what the censors had demanded.

The state could cut the film. It could not make the character obedient.

The Film the Dictatorship Tried to Bury

Zé do Caixão was Mojica's most famous creation, but Mojica was more than the caretaker of a single franchise. He made anthology horror, westerns, social satires, erotic films, hallucinations, and works that barely fit into any stable category.

One of the most fascinating is O Ritual dos Sádicos, later renamed O Despertar da BestaAwakening of the Beast. Completed around 1970, it mixes documentary-style discussions of drugs and social decay with dramatized violence and an LSD experiment built around the image of Zé do Caixão. The federal censors rejected it. The film remained prohibited for roughly fifteen years and was only released in the 1980s under its new title.

Scene from Awakening of the Beast

Scene from Awakening of the Beast, presented by Instituto Moreira Salles as part of its José Mojica Marins restoration program. Image © its respective rights holders.

There is a dark joke hiding in that history. A government obsessed with protecting society from dangerous images turned a low-budget filmmaker into forbidden mythology. Mojica already understood how publicity worked. Censorship merely gave him another costume.

He also brought horror into Brazilian homes. Beginning in the late 1960s, he appeared as Zé do Caixão on television, introducing and performing horror stories. Many recordings were later erased or reused, leaving a painful gap in Brazilian television history. In the 1990s, a new generation encountered him through Cine Trash, where he presented horror movies on TV Bandeirantes. He was no longer only a film character. He was a host, a mascot, and a familiar intruder in the afternoon schedule.

Outside Brazil, the rediscovery took longer. In the mid-1990s, American cult-video audiences began finding Mojica through VHS releases. The English name Coffin Joe helped package the character, but the films remained stubbornly themselves: Portuguese-speaking, Catholic-haunted, cheap, theatrical, and unmistakably Brazilian. What foreign viewers treated as a bizarre new discovery had already been living in Brazil's cultural basement for three decades.

Forty-Four Years to Finish a Trilogy

The third official chapter, Encarnação do DemônioEmbodiment of Evil — arrived in 2008, forty-four years after the first film. That gap alone makes the trilogy extraordinary. The same filmmaker returned to the same character not as a nostalgic cameo, but as an old man still determined to complete an argument interrupted by censorship, money, lost rights, and time.

Poster for Embodiment of Evil

Official promotional artwork for Embodiment of Evil, remotely hosted by Gullane. Artwork © its respective rights holders.

In the film, Zé is released after forty years in a prison psychiatric ward and returns to a modern São Paulo that has changed without becoming less violent. He resumes his search for the perfect woman and the continuation of his blood. The production is larger, the effects are more explicit, and the filmmaking is technically polished, but Mojica does not domesticate the character for a respectable comeback.

The movie also contains a moving production story. Veteran actor Jece Valadão became seriously ill during filming and died before completing his role. Rather than replace him and erase his final work, Mojica insisted that Valadão remain in the movie. The screenplay was restructured around the footage already filmed. For a director famous for cruelty on screen, it was a deeply loyal decision behind the camera.

And then there were the tarantulas. This time Mojica let them crawl across his own face on camera, partly as an answer to decades of accusations that he made performers endure things he would not face himself. At more than seventy years old, he was still proving the point physically. Sensible? Perhaps not. Consistent? Completely.

A Filmmaker Too Large for “So Bad It's Good”

Mojica spent years being dismissed as trash, exploitation, incompetence, or accidental comedy. Some of the films are uneven. Some effects are visibly improvised. Performances can swing from stiff to hysterical inside the same scene. But “so bad it's good” is a lazy way to describe an artist who knew exactly how to create an image people could not forget.

He understood faces, silhouettes, ritual, repetition, and confrontation. He understood that the camera could be addressed directly, almost assaulted. He understood that a limited set becomes convincing when the actor inside it behaves as if the universe ends at the wall. Most importantly, he understood that horror does not require permission from realism.

The films have humor, but they are not jokes. Their roughness belongs to the conditions that produced them and to Mojica's refusal to wait for better conditions. He was making personal genre cinema in Brazil before the cultural gatekeepers had a comfortable category for it. While more prestigious movements received academic respect, Mojica reached popular audiences and built an icon from the margins.

Today his work is being restored in 4K from original materials, screened by institutions such as the Instituto Moreira Salles and the BFI, and reconsidered as a central part of Brazilian and world horror cinema. The restoration is important because poor VHS copies shaped the international reputation of these films for years. Mojica's darkness was intentional. The mud was not always supposed to be there.

José Mojica Marins died in São Paulo in 2020, at the age of 83, from complications of bronchopneumonia. Zé do Caixão survived him, of course. Characters built around immortality tend to be annoying that way.

But I think the real continuation of blood was never the perfect son Zé kept demanding. It was the filmmakers Mojica influenced, the audiences he disturbed, the images he planted in Brazilian culture, and the proof that horror made here did not need to imitate anyone politely.

Brazil did not inherit Zé do Caixão from an old legend.

Mojica made the legend himself.

Read more

Pablo Murad pablomurad.com

Susan Kare Made the Computer Feel Human

There are people whose work becomes so familiar that it almost disappears. We use it, understand it, and move on without ever asking who made it. Susan Kare belongs to that rare group. Even if someone does not know her name, there is a good chance they have already met her ideas: the smiling Macintosh, the trash can, the paint bucket, the lasso, the Chicago typeface, the Command symbol, and even the cards in Microsoft Solitaire.

What I find remarkable is not simply that Kare designed a collection of famous icons. She helped teach ordinary people how to speak with a computer at a moment when most computers still felt like machines built for specialists. She gave the Macintosh a face, but more importantly, she gave it manners.

An Apple Macintosh 128K with its keyboard and mouse

The Macintosh 128K. Photograph by Christo, licensed under CC BY-SA 4.0, via Wikimedia Commons.

Before the Mac, There Was Graph Paper

Kare did not arrive at Apple as a computer expert. She had studied art, completed a doctorate in fine arts, worked in museums, and made sculpture. In a 2000 interview preserved by Stanford, she said her experience with computer graphics before Apple was exactly zero. That fact is usually presented as a charming piece of trivia, but I think it explains a great deal about why her work succeeded.

The Macintosh was supposed to reach people who did not already understand computers. Kare was one of those people. She was not trapped inside the assumptions of an industry that expected users to memorize commands and adapt themselves to the machine. She could look at an unfinished interface and feel the same uncertainty that a future customer might feel.

Her way into Apple came through Andy Hertzfeld, a high school friend who was working on the Macintosh team. He told her that the machine needed small graphics and suggested that she draw them on graph paper, one square at a time. She initially did the work in exchange for an Apple II. Later, she joined the project and learned the technical side while working.

The method was wonderfully direct. Each square on the paper represented a pixel. Fill one in, leave another empty, and gradually an object appeared. Kare compared bitmap graphics to mosaics and needlepoint, forms she already understood. The technology was new, but the underlying problem was ancient: how do you build a clear image from tiny individual pieces?

The limitations were severe. A typical icon had to survive inside a tiny black-and-white grid. There was no smooth shading to hide a weak silhouette, no huge canvas, and no high-resolution display to rescue an unnecessary detail. Every pixel had to earn its place. That constraint could have produced a cold and purely functional system. Instead, Kare found room for expression.

A Computer That Smiled Back

The original Macintosh did something emotionally intelligent before the user opened a document or clicked a menu: it greeted them with a smiling computer. The Happy Mac was extremely simple, yet it immediately changed the mood of the encounter. The machine was not presenting a wall of technical information. It was telling you, in the smallest possible visual language, that everything was fine.

That friendliness continued throughout the interface. Files looked like pieces of paper. Tools looked like tools. A trash can offered a visible place to discard something. MacPaint used a brush, a hand, a paint bucket, and a lasso. These images did not merely decorate the software. They made actions easier to predict.

The Museum of Modern Art describes Kare's icons as a language intended to be understandable across countries. That ambition matters. A successful icon is not just attractive. It should communicate quickly, stay recognizable at a terrible resolution, and remain memorable after the user learns it once. Kare herself said that a good icon should either be instantly understandable or easy to remember after a single explanation.

Her process was practical rather than mystical. She created alternatives, showed them to people, listened to their reactions, and refined the result. When the MacPaint team needed an icon for filling an area, she explored paint rollers and other possibilities. The pouring paint can won because people understood it. That is such a healthy design lesson: the cleverest idea is not automatically the clearest one.

Some experiments did fail. At one point, Kare tried to represent copying with a cat looking into a mirror — a literal “copycat.” It was funny, but the pun would not travel across languages. She abandoned it. The story makes me like her work even more because it shows the judgment behind the simplicity. Good visual design is not the absence of playful ideas. It is knowing which playful idea belongs in the final interface.

The Command Symbol Has a Strange Little History

One of Kare's most enduring contributions began with Steve Jobs complaining that the Apple logo appeared too many times in the menus. Keyboard shortcuts were marked with a small Apple, and Jobs wanted another symbol immediately.

Kare searched through a book of international symbols and found the looped-square sign now associated with the Command key. The exact folklore around it became muddled over time. An early account described it as a Swedish campground symbol for an interesting feature. Swedish readers later corrected the detail: the sign is associated more broadly with places of interest or historical sites. The important part is that Kare did not pretend a random shape already had meaning. She searched for a real symbol, selected one that remained legible as a small bitmap, and gave it a new technological life.

A Macintosh Command key showing the Apple and looped-square symbols

A Macintosh Command key from the 1990s. Illustration by Daniel Beardsmore, released into the public domain, via Wikimedia Commons.

Today I can see ⌘ in a menu and understand it without reading a word. It feels inevitable, as if computers always had to use that shape. They did not. Someone had to notice it in a reference book, understand its potential, redraw it for a tiny grid, and persuade everyone else that it worked. Invisible decisions like this are what make Kare's career so interesting.

She Also Gave the Macintosh Its Voice

Kare's work was not limited to pictures. She designed bitmap typefaces for the Macintosh, including Chicago, Geneva, Monaco, New York, and the wonderfully strange pictographic font Cairo. Each font had to remain readable on a low-resolution screen, which meant making decisions letter by letter and pixel by pixel.

Chicago became the loud, compact voice of the classic Mac interface. It appeared in menus and system text for years, and later survived on early iPods. Monaco offered monospaced clarity. Cairo replaced conventional letters with small images and symbols, including the creature that became Clarus the Dogcow, an Apple cult character that began as a tiny glyph and somehow acquired a name, a sound, and a life of its own.

People sometimes describe Cairo as a precursor to emoji. That comparison makes sense as long as we do not flatten the history. Cairo was not the direct origin of modern emoji, but it demonstrated how pictograms could live inside a font and enter text as characters. It belonged to the same larger human desire: sometimes a small picture communicates a tone or idea more quickly than another sentence.

Kare later brought the same precision to other platforms. For Microsoft, she designed interface graphics for Windows 3.0 and the card deck used in Solitaire. Those cards appeared on an absurd number of computers, teaching generations of users to click, drag, and release with a mouse while they thought they were only wasting a few minutes. Her work also reached NeXT, IBM, General Magic, Facebook, Pinterest, and many other companies.

This is another reason I admire her. Her legacy cannot be reduced to nostalgia for beige Macs. She developed a way of thinking about interface graphics — direct, economical, friendly, and grounded in recognition — then carried it across very different products.

The Art Was Small, but It Was Never Minor

For a long time, software icons were treated as details. Programmers built the “real” system, while the pictures were assumed to be finishing touches. Kare's work exposes how wrong that distinction is. The icons were part of the machine's behavior. They shaped what people noticed, what they understood, and whether they felt comfortable experimenting.

There is a wonderful contradiction in her career. The images were tiny, but their cultural reach was enormous. They were black and white, yet full of personality. They were designed to disappear into use, but they eventually entered museum collections. In 2015, MoMA acquired her 1982 Macintosh icon sketchbook, preserving the graph-paper drawings as part of design history. Kare received the AIGA Medal in 2018 and Cooper Hewitt's Lifetime Achievement award in 2019.

That recognition was deserved, but also revealingly late. We are still learning to credit the people who shaped the everyday language of software. An interface becomes “intuitive” only after designers have spent a ridiculous amount of time deciding what should be visible, what can be removed, and how a person will interpret a handful of marks on a screen.

Kare once explained that sparse, refined design can make a system feel easier to use. I keep returning to that idea because it sounds obvious only after someone says it. Clutter does more than make a screen ugly; it makes the machine feel less certain. A well-made icon reduces that uncertainty. It does not show off. It helps.

Why Her Work Still Matters

Modern interfaces have more pixels, more colors, more animation, and far more processing power than the first Macintosh. Yet they are not automatically clearer. In fact, abundance often creates its own laziness. When every icon can have gradients, shadows, movement, and microscopic detail, it becomes tempting to solve a communication problem with decoration.

Kare's work is a useful correction. Start with meaning. Make the silhouette readable. Remove what is not helping. Test whether another person understands it. Give the object enough character to be memorable, but never so much personality that function disappears.

What I love most is the humanity inside that discipline. Kare did not make the computer friendlier by covering it in empty cheerfulness. She made it friendlier by respecting the person sitting in front of it. The smile mattered because the rest of the interface kept the same promise: you should be able to look, try, make a mistake, and continue.

Susan Kare did not merely give the Macintosh its face. She helped establish the visual grammar that made personal computers feel personal. Decades later, her best work still performs the same trick. We recognize it immediately, use it without ceremony, and almost forget that someone had to draw every single pixel.

Read more

Pablo Murad pablomurad.com

Movie: Bloodlust

Some vampire films are polished until there is nothing dangerous left in them. The castles are perfect, the costumes are expensive, the blood is tastefully arranged and the vampire looks ready for a perfume advertisement.

Subspecies is not that kind of film.

Released in 1991 by Full Moon Entertainment and directed by Ted Nicolaou, Subspecies feels old, strange and slightly unclean in the best possible way. It has ruins, forests, local legends, small creatures crawling across stone floors and a vampire who looks as if he has spent centuries sleeping somewhere that nobody should open.

I liked it a lot.

It is not a flawless movie, and I do not need it to be one. What it has is atmosphere, personality and a villain who immediately belongs to the screen. In a genre filled with elegant counts and romantic immortals, Radu arrives with long fingers, a ruined face, a wet voice and absolutely no interest in being charming.

Well, perhaps he thinks he is charming.

That only makes him better.

The story of the first Subspecies

This section discusses the complete story, including the ending.

The film opens inside Castle Vladislas, where King Vladislav is confronted by his exiled son Radu. The king, played by Angus Scrimm, has maintained an old agreement between vampires and the nearby human population. A mystical relic called the Bloodstone provides blood said to come from the saints, allowing the vampires to survive without feeding on the villagers.

Radu does not care much for peaceful coexistence.

He murders his father and takes the Bloodstone, not only because he desires its power, but because he sees it as his inheritance. This first scene establishes everything important about him. Radu is not simply hungry. He is resentful. He believes the world has denied him something, and therefore everything he does afterward becomes, in his mind, a form of correction.

At roughly the same time, three students arrive in the Romanian town of Prejmer. Michelle and Lillian are Americans visiting their Romanian friend Mara. They are there to study local culture, myths and superstitions, which is the sort of academic plan that sounds wonderful until the local superstition begins walking behind you at night.

The women stay in an old fortress and begin learning about the region's vampire legends. They hear stories about the Bloodstone, King Vladislav and an annual Festival of the Undead. The villagers do not treat these stories as distant entertainment. Their customs, celebrations and fears suggest that folklore is still part of ordinary life.

The students also meet Stefan, a young zoologist who appears gentle, educated and strangely comfortable with nocturnal animals. Michelle and Stefan become attracted to each other, but he is hiding a rather substantial family detail.

Stefan is a vampire.

More specifically, he is Radu's half-brother. Unlike Radu, Stefan rejects the cruelty associated with his nature and tries to live without hunting human beings. He represents the possibility that a vampire is not automatically condemned to become a monster. Radu, naturally, finds this offensive.

The conflict between the brothers soon reaches the three women. Radu begins stalking them, attacks Lillian and later takes Mara. He wants them as consorts, but he also understands that harming Michelle will hurt Stefan. For Radu, desire and revenge are almost the same emotion.

Lillian becomes ill after the attack and eventually dies, only to rise again as a vampire under Radu's influence. Mara suffers a similar fate. Michelle, meanwhile, discovers Stefan's true identity and becomes trapped between the two brothers: one wants to protect her, while the other wants to corrupt everything his brother loves.

The final confrontation takes place at the castle. Radu holds Stefan and the women captive, intending to claim Michelle in front of him. Karl, a local man who understands the old vampire traditions, joins the attempt to stop Radu. The battle is chaotic, physical and appropriately Gothic. Radu is staked and beheaded, while Lillian and Mara cannot be restored to their former lives.

But Radu has already bitten Michelle.

To prevent her from becoming like Radu, Michelle asks Stefan to turn her himself. The film closes with Stefan and Michelle together in his coffin, apparently safe, while Radu's small servants gather around the remains of their master.

The evil is defeated.

Temporarily.

Radu is the reason the film survives

I enjoy the locations, the folklore and the strange little title creatures, but Anders Hove's Radu is the image that remains after the film ends.

His performance is wonderfully excessive. Radu does not enter a room like a normal person. He leaks into it. His body bends forward, his hands seem too long for him, and every word sounds as though it has been pulled through a throat full of old blood.

The character owes something to the tradition of Count Orlok in Nosferatu, naturally, but Hove does not play him as a silent copy. Radu speaks, complains, threatens, envies and enjoys his own cruelty. There is something almost childish beneath the monster: an exiled son returning home to demand what he thinks should always have been his.

That combination makes him more memorable than a simple animal.

Radu is grotesque, but he has grievances.

Hove later explained that the voice was invented almost spontaneously. During filming, Ted Nicolaou asked him how the character should speak, and the actor produced the now-familiar voice on the spot. When Hove later suggested changing it for the sequels, Nicolaou refused. He was stuck with it.

Fortunately, so were we.

Romania does half the special effects

The smartest decision in Subspecies was filming in Romania.

Many inexpensive horror movies try to manufacture an ancient world by pointing smoke machines at a few artificial stones. This film did not need to pretend quite so much. It had real fortifications, real forests, old walls and a landscape carrying its own history.

The production arrived during an extraordinary moment. Filming took place shortly after the fall of Nicolae Ceaușescu's regime, when Romania was moving out of decades of communist dictatorship. Academic discussion of the film identifies it as the first vampire movie shot in post-communist Romania, while it is also widely credited as the first American production filmed in Bucharest after that political transformation.

That context matters because the scenery does not look like a carefully controlled tourist version of Transylvania. It feels unsettled. The villages, cemeteries and ruins give the film a scale that its budget alone could never have purchased.

Anders Hove remembered that the crew expected to stay for roughly four or five weeks but remained for about fourteen. Production was repeatedly affected by the unstable conditions of the period. He also recalled a huge hotel with space for hundreds of guests occupied by only their small crew.

That sounds lonely.

It also sounds exactly like a Full Moon vampire film.

The tiny creatures are strange, unnecessary and perfect

Radu can create little demonic servants, the Subspecies of the title, from pieces of his own fingers. They crawl around, obey commands and eventually prove that a loyal employee can be useful even when he is only several inches tall and animated one frame at a time.

The creatures were realized through stop-motion effects associated with David Allen's visual-effects team. They do not appear constantly and, honestly, the central story could survive without them. The title promises more Subspecies than the film actually delivers.

I do not care.

Their limited presence makes the movie stranger. They feel as if they escaped from another Full Moon production, wandered into Radu's castle and received immediate employment. They also become important in the final image, gathering around Radu and preparing the continuation.

The film knows it has a sequel before the audience has finished the first one.

That confidence is admirable.

The makeup was a small daily nightmare

Radu's face and hands are essential to the character, but wearing them was apparently not pleasant. Hove said the makeup application took approximately three to four hours, followed by another two hours to remove it after a day of filming.

Think about that for a moment.

Before Radu could stalk anyone through a castle, the actor had already spent half a working day becoming Radu. After the final scene, he still had to sit for hours while the vampire was removed from him piece by piece.

Hove said red wine helped him through the removal process.

Reasonable.

The effort appears on screen. Radu does not look like an actor with pale foundation and plastic teeth. The makeup changes the architecture of his face. Combined with the fingernails, hair, posture and voice, it creates a complete silhouette. You can recognize him in shadow.

That is good monster design.

What does not work

The film has limitations.

Some performances are stiff. The romance between Michelle and Stefan develops more because the story requires it than because the actors generate an irresistible connection. The pacing can be slow, and the students occasionally behave with the relaxed curiosity of people who do not realize they have entered a vampire movie.

The plot is also simple. There is an evil brother, a good brother, a magical relic and a group of people placed between them. The film is not hiding an elaborate puzzle.

But simplicity is not the same as emptiness.

The story gives the locations room to breathe and gives Radu time to establish himself. The weaknesses even contribute to the peculiar tone. Subspecies feels less like a modern studio product and more like a forgotten regional legend reconstructed by a film crew that had limited money, great locations and one extraordinary vampire.

I would rather watch a movie with visible limitations and a real identity than a technically perfect film with nothing in its blood.

Why I liked it so much

I liked Subspecies because it takes vampires seriously without becoming respectable.

It embraces castles, blood relics, festivals, coffins, ancient family disputes and creatures born from severed fingers. There is no embarrassment about being a Gothic vampire film. At the same time, it has the handmade energy of early Full Moon productions: practical makeup, stop-motion monsters, ambitious locations and a story constructed to continue beyond the final frame.

Most importantly, it gave horror a genuinely memorable vampire.

Radu is not beautiful. He is not misunderstood in a convenient romantic way. He is jealous, theatrical, disgusting and completely committed to his own importance. The film becomes alive whenever he appears.

That is enough for me to forgive quite a lot.

My rating: 8/10

Subspecies is not an 8/10 because every performance works, every effect convinces or every scene moves perfectly.

It is an 8/10 because it creates a world.

It uses Romania as more than a background, introduces a villain who could carry an entire franchise and understands the pleasure of an unapologetic vampire story. Its roughness is visible, but so is its imagination.

Some films have more money.

Subspecies has Radu walking through a real Transylvanian ruin with the Bloodstone between his impossible fingers.

I know which one I would rather watch.

Read more

Pablo Murad pablomurad.com

Copyparty Is the File Server I Did Not Know I Needed

A practical love letter to a portable file server that keeps surprising me.

You know that rare kind of software that solves the problem you installed it for, then quietly reveals that it can solve another five?

Well, I think I found one.

I have been using copyparty, and I am honestly loving it. At first, the idea sounded almost too simple: run a program, point it at a directory, open a browser, and there are your files. But copyparty is one of those projects where “simple” describes the entrance, not the building.

Behind that browser window is a remarkably capable file server. It can handle resumable uploads, searchable indexes, duplicate detection, user accounts, per-folder permissions, media previews, WebDAV, SFTP, FTP and several other useful things. It runs on an absurd variety of systems. It can be a quick file drop, a personal web drive, a media shelf, a bridge between old machines or the front end of a much larger storage setup.

And somehow it still feels light.

The first thing I loved: almost no ceremony

There is a particular exhaustion that comes with self-hosted software. Sometimes you want one useful service and receive, as a bonus, a database, a cache, an identity platform, six containers and a small career in YAML maintenance.

Copyparty goes in the opposite direction.

The server itself only needs Python; its additional dependencies are optional and unlock extra features. The official project provides a self-contained Python version, a Python zipapp, a Windows executable and a Docker image. It can run on Linux, Windows, macOS, Android, iOS and a rather entertaining list of less common architectures and operating systems.

The fastest test is almost suspiciously easy:

python copyparty-sfx.py

That is enough to turn the current directory into a browser-accessible file server. For a quick transfer on a trusted network, a temporary lab or an emergency involving two machines that refuse to cooperate, this is wonderful.

There is an important warning, however: running copyparty without arguments gives everyone who can reach it read and write access to the current folder. That default is convenient for testing, not a production security policy. Before exposing it beyond a trusted network, configure accounts, volumes and permissions, and place it behind properly configured HTTPS when appropriate.

Easy to start does not mean safe to forget.

It is much more than a page with files

The web interface is where copyparty stopped feeling like a clever transfer utility and started feeling like infrastructure I could actually keep.

From the browser, I can navigate directories, upload and download files, create folders, rename items, move or copy them and undo accidental uploads when the server allows it. Folders can be downloaded as ZIP or TAR archives. There is a navigation pane, keyboard shortcuts, thumbnails, an image gallery, a Markdown viewer and even media-oriented features such as audio playback, playlists and server-side transcoding when the optional tools are available.

This matters because a file server should not require every person to understand a file server.

Give someone a clean web address and the interaction is already familiar. They do not need to mount a network share. They do not need an FTP client. They do not need to learn which operating system is running on the other side. A browser is enough.

But if a browser is not enough for your workflow, copyparty also speaks WebDAV, SFTP, FTP, TFTP and SMB/CIFS, depending on configuration and platform support. It can announce services on a local network through Zeroconf, mDNS and SSDP. The same collection of files can therefore meet modern browsers, desktop file managers, command-line tools and old machines without forcing everything through one narrow door.

That flexibility is not decorative. It is useful.

Resumable uploads are the feature you appreciate after something fails

Large uploads are easy to advertise when the connection is perfect. Real networks are less polite.

Copyparty's main browser uploader, called up2k, supports resumable and multithreaded transfers. If an upload is interrupted, the useful response is not to punish the user by starting again from zero. The server and browser can continue the work.

This is one of those features that sounds technical until you need it. Then it becomes the entire reason you trust the software.

It also supports write-only folders, filename randomization, self-destructing uploads and content-based duplicate handling. A write-only volume is especially interesting for collecting files: people can submit material without receiving permission to browse everything that other people have uploaded. With the right volume permissions, copyparty can behave like a private drop box instead of a public cupboard.

Search, indexing and the quiet intelligence of deduplication

Enable file indexing with -e2dsa, and copyparty can scan the shared volumes and make them searchable from the web interface. Searches can use names, paths, dates and sizes. With media indexing enabled, metadata such as audio tags can also become part of the search.

The clever part is that indexing is connected to duplicate detection. Copyparty can compare content and avoid storing the same upload repeatedly. You can even drag a local file into the search interface to check whether identical content already exists somewhere on the server.

This changes how the server feels as a collection grows. It is no longer only a directory exposed over HTTP. It begins to understand enough about the collection to help you navigate it.

Not everything needs artificial intelligence.

Sometimes a good index is the intelligent thing.

Accounts and volumes make it practical

The permission model is another reason I can imagine copyparty serving very different roles without becoming a mess.

A volume maps a real directory to a location in the web interface. Each volume can have its own access rules, and permissions can be assigned to users. One folder may be public and read-only. Another may allow uploads but hide its contents. A private directory may be visible only to one account, while an administrator receives broader file-management permissions.

This separation is simple enough to understand and flexible enough to matter. I can think in terms of actual use:

  • a public download area;
  • a private family archive;
  • an upload-only inbox;
  • a music library with browser playback;
  • a working directory available through WebDAV;
  • a temporary share with limited permissions.

One service. Different doors. Different keys.

For longer-lived installations, the project recommends using a configuration file rather than accumulating a heroic command line. The official example shows global options, accounts and volumes in one readable file, and account or volume changes can be reloaded without restarting the entire service in supported setups.

It respects small machines

Perhaps this is the part I like most.

Copyparty does not begin with the assumption that every useful service deserves a new server. Its project philosophy is wonderfully direct: run anywhere, support everything, require little preparation and keep dependencies minimal.

That makes it useful on a home server, naturally. But it also makes it interesting on an old laptop, a small single-board computer, a temporary virtual machine or some forgotten device that still has a disk, a network connection and enough life left to be useful.

Software like this gives hardware a second purpose.

And there is something deeply satisfying about that. We are surrounded by machines that are declared obsolete long before they become incapable. A portable file server will not solve electronic waste, naturally, but it can turn an idle computer into a useful point of exchange again. Sometimes the difference between junk and infrastructure is one good piece of software.

Docker when I want it, plain Python when I do not

I also appreciate that copyparty does not turn deployment preference into a religion.

If I want the direct route, I can run the standalone Python package. If I want a container, the project maintains the copyparty/ac image and provides Compose examples. If I am on Windows, there is an executable. If I want to integrate it with a service manager, reverse proxy or a more carefully designed storage layout, the configuration is there.

This is how portable software should behave. It offers choices without making every choice mandatory.

For a serious deployment, I would still treat it like any other internet-facing service: use a dedicated account, expose only the directories that are necessary, grant the smallest useful permissions, keep the software updated, use HTTPS, understand the reverse-proxy headers and review the project's hardening guidance. Anonymous uploads deserve additional care, particularly around browser-rendered HTML, SVG and Markdown content.

Copyparty makes the first ten minutes easy. Security remains our job after that.

Why I am enjoying it so much

There are larger platforms. There are more polished cloud suites. There are systems with calendars, collaborative documents, contact synchronization and enough plugins to recreate an office building inside a browser.

Copyparty is not trying to become all of that.

It is trying to move, organize, expose, search and play files extremely well across an unreasonable number of environments. Its own documentation jokes about following an “inverse Unix philosophy”: do all the things, and do an okay job. The joke is fair, but it hides the impressive part. The features do not feel like a random pile. They orbit the same practical question:

How can I make these files useful from another device?

That question appears simple. It is not. Different users, unreliable uploads, old protocols, browsers, media, permissions, duplicate data, reverse proxies and strange operating systems all arrive eventually. Copyparty has apparently met most of them already.

And this is why I am loving it.

It does not demand that I build my life around the software. It arrives, points at the files and becomes useful. Then, when I need more, there is usually another switch, another protocol or another carefully strange feature waiting in the documentation.

Some software wants to be a platform.

Copyparty wants to be invited to the party, carry every box through the door and make sure nothing gets lost on the way.

Honestly, it can stay.

Read more

Pablo Murad pablomurad.com

How I built a public fax wall

https://fax.1208.pro

There is a working fax number out in the world and it belongs to me. If you send a page to it, that page may end up hanging on a web page, in black and white, for any stranger to look at. No signup, no login, no app. Just a phone number and an old machine on the other end of an imaginary line.

The idea came out of a small grudge. "Just send the link" has become the beige wallpaper of modern life. Everything is instant, everything is editable, everything disappears. Fax isn't like that. Fax is stubborn, far too physical for 2026, and somehow still alive — running like a haunted appliance with union protection. I wanted to see what would happen if I left that channel open to the world and published whatever came through without much curation.

This is about how the thing was built. I'll walk through each piece in the order a sheet of paper travels through the system.

The rule that shaped the architecture

Before the first file existed, I had one personal constraint: no application server.

No backend running around the clock, no database to back up, no container to patch at three in the morning because somebody published a CVE. This is a toy project. If it demands maintenance, it dies in three months. They all do.

So the decision was: the final site is static HTML. One file. Nginx serves it and that's the end of it. Everything dynamic happens far away, on infrastructure somebody else maintains for free, and the result lands as a dumb JSON file on disk.

That sounds like laziness. It is, but it's laziness with a design behind it. Every other choice in the system falls out of this rule.

Piece 1: getting a real fax number

I don't have a phone line and I'm not buying a machine. I used a fax-to-email service — fax.plus, in this case. You rent a number, and when somebody transmits to it, the service answers the call, decodes the signal, and emails you the document as an attachment, PDF or TIFF.

The number is published on the site, because that's the entire point: +1 762 475 9826.

Here's the trick that made the project viable: the moment a fax becomes an email with an attachment, it stops being telephony and becomes a Gmail inbox. And a Gmail inbox is something I know how to automate without paying anyone.

Piece 2: Google Apps Script playing the part of a backend

The whole core of the system lives in a single Código.gs file running on Google Apps Script. It's JavaScript hosted by Google with native access to Gmail, Drive, and Sheets, time-based triggers, and a public HTTP endpoint if you want one. It costs nothing and I administer none of it.

It has two main functions that run in sequence.

ingestFaxes() — fishing out the new faxes

The function sweeps Gmail with a specific query: messages from the fax service's notification sender, within the last 30 days. The search is paginated 100 threads at a time in a loop, because the Apps Script API hands results back in batches, and ignoring that is the classic way to silently lose older messages once volume grows.

For each message, three decisions:

Have I seen this one? Before anything else, I compare the Gmail message ID against the IDs already on record. This is the heart of the system's idempotency. The trigger fires periodically and always looks at a rolling 30-day window, which means it reprocesses the same messages dozens of times. Without a reliable dedup key, the wall would turn into a wall of duplicates. Using the message ID rather than the subject or the date was the right call: subjects repeat, dates collide, IDs don't.

Which attachment is the fax? A notification email arrives with a logo, a footer, an inline image, sometimes a receipt. I wrote a function that filters for plausible candidates — .pdf, .tif, .tiff, or the matching content types — and then scores the survivors: PDF is worth 3, TIFF is worth 2, and file size enters as a tiebreaker divided by ten million. Highest score wins.

There's one ugly case in there that I left on purpose: if an attachment shows up as application/octet-stream and is larger than 1 KB, I accept it. Fax gateways are notorious for sending generic content types. Rejecting on technical purity would mean dropping legitimate faxes, and the cost of the opposite error is low — worst case, Cloudinary refuses the file downstream and the row simply ends up without an image.

Keep the original. The chosen attachment is copied into a Drive folder under a deterministic name: fax_20260410_143022_<messageId>. That's the raw file, untouched. If I break every other stage of the pipeline, the source material is still sitting there.

Then a row goes into the spreadsheet with its own UUID, the received date, the sender, the subject, the Drive file ID, the attachment name and type, and a short public slug derived from the first eight characters of the UUID.

The spreadsheet as a database

Yes, the database is a fifteen-column Google Sheet. I recommend this far more often than people expect for projects this size. It comes with a free admin interface, revision history, permissions, and export, and I can fix a bad record from my phone on the bus without touching SSH.

One thing I did that saved me pain: a function that checks and repairs the sheet's header row every single time the script runs. It verifies the fifteen columns are where they should be, inserts columns if any are missing, and rewrites the header row if it doesn't match. A spreadsheet is human-editable, and humans drag columns around. Assuming the schema is intact is optimism. The code never reads a column by fixed position — it always builds a name-to-index map from row 1.

Every row is born with status approved. The schema supports moderation — the field is there, and the publishing function only looks at approved rows — but in practice the gate is wide open. That was a deliberate choice about the spirit of the site, not an oversight. The switch exists for the day I need it.

publishApproved() — turning a PDF into a publishable image

Second function. It looks for approved rows that don't have a published image yet, pulls the original file from Drive, and pushes it to Cloudinary under a predictable public ID.

And this is where my favorite part of the whole project lives, because it's work I didn't have to do.

The real problem was: how do you turn a fax PDF into an image that fits on a web page? The obvious answer involves rasterizing PDF, which means ImageMagick or Ghostscript, which means a server, which breaks rule number one.

Cloudinary does it in the URL. After the upload, the public image is assembled from four transformations chained into the path:

  • pg_1 — take only the first page of the PDF
  • dn_200 — rasterize at 200 DPI, fax density
  • e_blackwhite — force pure black and white, no halftone
  • q_auto — let Cloudinary pick the compression

No processing happens on my side at all. I upload the raw PDF and request an image by URL. The conversion runs on their CDN, on demand, and gets cached. The result looks exactly the way I wanted — fax grain, high contrast, none of that tasteful gray.

The image URL and the public ID go back into the spreadsheet, and the row is live.

Piece 3: privacy, or at least the facade of it

The wall displays pages that strangers sent me. I have no idea what's going to arrive. So there's a hygiene layer working at two levels.

On the server side, the sender runs through a function that tries to extract a human name from the email's From field. It handles the "Some Person" <person@domain.com> format, strips outer quotes, and then applies two rejection tests: if what's left looks like an email address, discard it; if it looks like a phone or fax number — seven or more digits, nothing but phone characters — discard it. Anything suspicious becomes Unknown sender.

This matters more than it looks. Fax carries the originating number in its header, and the service frequently drops that number straight into the sender field. Publishing it would mean leaking the phone number of whoever sent me a joke.

On the client side I went further: the page doesn't render the sender name at all. The public JSON carries the field, the front-end simply never draws it. Every entry on the wall shows the date, the image, and an optional note. That's it. As the site's own FAQ puts it — the machine knows, obviously, but it is not a snitch.

Piece 4: how the data leaves Google and reaches my VPS

The Apps Script publishes a Web App with three output formats, selected by URL parameter: a rough HTML gallery, format=json, and format=rss. The last two are the ones that matter. Both accept a per-item filter and a count limit.

Now, I could have just pointed the site at Google's endpoint and called it done. I didn't, for three reasons: the URL is ugly and advertises the implementation, Apps Script has execution quotas, and I don't want my site's availability to depend on a Google redirect resolving during a visitor's request.

So there's an eighty-line shell script running hourly on the VPS via cron. It's small, but there are four deliberate decisions inside it.

Cascading configuration. The script loads config files in order — repository first, then ~/.config — and environment variables override everything. That gives me versioned defaults plus machine-local overrides without a single if.

Explicit failure. It opens with set -euo pipefail and aborts with a clear message if the Web App URL isn't configured. A silent cron job is the worst category of bug: the site just freezes in time and nobody notices for three weeks.

URL rewriting. Every link pointing at Google's domain inside the payload gets swapped for the public domain, using perl with both ends passed in through environment variables instead of interpolated into the regex. That keeps a slash or a question mark in the URL from turning into a metacharacter and destroying the file.

Atomic writes. This is the most important one and the easiest to forget. curl downloads to faxes.json.tmp, and only then does mv move it over the real file. mv within the same filesystem is an atomic rename, which means nginx never — not for a single instant — serves a half-written JSON. If you download straight over the file being served, sooner or later a visitor catches the transfer mid-flight and gets a parse error.

Nginx serves both files with Cache-Control: max-age=3600, deliberately aligned to the cron interval. There's no point caching for longer than the refresh, or for shorter.

Piece 5: the page

The front-end is a single HTML file. No build step, no framework, no dependencies, no npm install. It loads the JSON with fetch and assembles the list.

The look is monospace, light gray background, blue underlined links in the browser's original default. That's not aesthetic laziness — that is the aesthetic. A fax wall shouldn't look like a SaaS product.

A few details worth mentioning:

The favicon is a fax emoji embedded as an SVG data URL. Zero extra requests, no .ico file.

The image containers have aspect-ratio: 210 / 297 locked in before any image loads. That's the A4 ratio. The space is reserved at the correct size up front, so nothing jumps around when the images arrive.

The first image loads with loading="eager" and fetchpriority="high"; every other one is lazy. There's a preconnect to Cloudinary's domain in the head, so the TLS handshake is already underway before the JSON finishes downloading.

The JSON parser is intentionally forgiving: if the response is an array, use it; if it's an object, look for items, faxes, records, data, or entries. It cost six lines and lets me change the backend's shape without breaking the live page.

And everything coming out of the JSON gets HTML-escaped before it touches the DOM. I am rendering content that anonymous strangers transmitted over a phone line. Concatenating that straight into innerHTML would be an open invitation for the first clever visitor to write the rest of my site for me.

Piece 6: the doorman

Sitting in front of all of it is Anubis, a proxy that demands proof-of-work from the browser before it lets the page through. It's there because AI training crawlers started burning through small-VPS bandwidth as if it were infinite.

I found out it was stricter than I remembered when I tried to fetch my own site with an automated tool and got an "access denied" to the face. Working as designed, I suppose.

The whole flow, in one line

Somebody dials the number → the service converts the call into an email with an attachment → Apps Script finds the email, dedupes on the message ID, picks the right attachment, archives the original in Drive, and writes a row to the spreadsheet → it uploads the PDF to Cloudinary and requests page one in black and white at 200 DPI → it publishes a JSON feed → the VPS cron pulls that JSON hourly and atomically swaps it over the old one → nginx serves it → static HTML draws it.

Not one process of mine runs continuously. The most complex part of the system — rasterizing PDF — is a parameter in a URL. The part that most resembles a database is a spreadsheet I can open on my phone.

What I'd do differently

Two things bother me when I reread the repository.

First: the Web App URL is committed to the environment file inside the repo, and the README itself says it should live outside of it. It's a read-only endpoint, so it isn't catastrophic, but it's exactly the kind of thing you swear you'll fix later and never do. The right move is to purge it from history, publish a new Web App version, and keep the value only in ~/.config on the VPS.

Second: the data/faxes.json sitting in the repo is empty. It's a placeholder — the real file is generated on the VPS by cron — but a committed empty file is a trap waiting for someone to deploy it over the good one.

Beyond that, I'd keep all of it. Especially the part where there's no server to maintain.

Read more

Pablo Murad pablomurad.com

Movie: A Serious Man

A Serious Man is one of the funniest films I have ever watched, although “funny” feels like an inadequate word for what the Coen brothers are doing here. This is not comedy designed to make you comfortable. It is comedy extracted from humiliation, spiritual confusion, domestic collapse and the quiet suspicion that the universe may be operating according to rules no one bothered to explain to us.

I loved it.

The film follows Larry Gopnik, a physics professor living in Minnesota in 1967, whose life begins to fall apart with almost mathematical precision. His wife wants a divorce so she can marry Sy Ableman, a man so calm, reasonable and unbearably sympathetic that his politeness becomes a form of violence. Larry’s brother is sleeping on the couch, draining a cyst in the bathroom and working obsessively on a mysterious notebook. His children treat him like an inconvenient financial institution. A student tries to bribe him and then threatens to sue him. His tenure is uncertain. His roof needs repairing.

And Larry, poor bastard, keeps asking the same question:

Why?

That is the real joke.

Larry is a man of science. He teaches uncertainty, probability and the limitations of human knowledge, but he cannot tolerate uncertainty when it enters his own house. He can explain Schrödinger’s cat on a blackboard, yet he cannot understand why his wife suddenly prefers Sy Ableman. He wants the universe to present its calculations. He wants suffering to show its work.

But the universe does not care about intellectual consistency.

A Serious Man is essentially the Book of Job relocated to a Jewish suburb, except that God never arrives to explain Himself. There are rabbis, lawyers, doctors, neighbors and academic committees, but no authority capable of giving Larry a useful answer. Every person he consults offers either a meaningless story, a bureaucratic procedure or some variation of “accept the mystery.”

The rabbis are especially brilliant. Larry approaches them expecting ancient wisdom and receives anecdotes, platitudes and administrative delays. One rabbi tells him a strange story about a dentist who discovered Hebrew letters carved into a patient’s teeth. The story sounds as though it must contain a profound revelation. Naturally, it leads nowhere.

That is exactly the point.

Human beings are addicted to the idea that everything means something. We cannot accept random suffering, so we invent structures around it. Religion, mathematics, superstition, philosophy, bureaucracy — all of them become methods of drawing borders around the incomprehensible. We ask for signs, and when a sign appears, we immediately ask what the sign means.

Maybe it means nothing.

Maybe the teeth are just teeth.

The humor in A Serious Man is brutally precise. The Coens never beg for laughter. They simply allow situations to become so uncomfortable, so absurdly unfair, that laughter becomes the only honest reaction. Sy Ableman embracing Larry and telling him how difficult the divorce must be is funnier than any conventional joke. He steals the man’s wife and then comforts him about it. It is an almost perfect portrait of passive aggression disguised as compassion.

Fred Melamed plays Sy with a kind of majestic softness. Every word sounds therapeutic. Every gesture feels murderous.

Michael Stuhlbarg is equally extraordinary as Larry. His performance is built from confusion, suppressed panic and the exhausted decency of a man who still believes that behaving correctly should protect him from catastrophe. Larry is not heroic, but he is recognizably human. He does not demand happiness. He merely wants an explanation.

He does not get one.

What makes the film even better is that it refuses to turn Larry into a saint. He is passive, indecisive and frequently blind to the people around him. He spends so much time asking what God wants from him that he rarely considers what anyone else might need. His suffering is real, but so is his self-absorption.

This prevents the film from becoming a simple story about an innocent man punished by fate. Larry may not deserve what happens to him, but the universe has never been particularly interested in proportional punishment.

The cinematography is controlled, clean and almost clinical. Everything in Larry’s world appears organized: suburban houses, synagogue rituals, university offices, mathematical formulas. Yet beneath that order is complete chaos. The visual neatness makes the existential disorder even funnier. The furniture is aligned. The lawns are trimmed. God remains unavailable.

Then there is the ending.

No sentimental resolution. No final rabbinical wisdom. No comforting proof that suffering produces growth. Larry makes one small moral compromise, the doctor calls with troubling news, and a tornado approaches his son’s school while Jefferson Airplane plays.

The film ends not with an answer, but with an interruption.

It is perfect.

The tornado is not merely punishment. That interpretation would be too easy, and A Serious Man distrusts easy interpretations. It is uncertainty made visible — enormous, indifferent and moving directly toward everyone. The children stare at it because there is nothing else to do.

That final image stayed with me because it expresses the whole film without pretending to solve it. We live inside systems we barely understand. We make plans, study sacred texts, calculate probabilities, repair antennas and worry about television reception while something immense forms on the horizon.

And still, somehow, it is hilarious.

A Serious Man is not nihilistic, although it frequently looks in that direction. Nihilism would be simpler. The film’s position is more disturbing: meaning may exist, but we may be fundamentally incapable of accessing it. God may have a plan. God may be absent. God may simply have a very strange sense of humor.

The Coen brothers do not answer the question.

They understand that the question is funnier.

Read more

Pablo Murad pablomurad.com

No Legacy, Just Curiosity

I do not want fame, nor do I care about leaving a legacy. It makes little difference to me whether I am known or not. I simply want to learn and understand.

Many things are driven by hype, while others make us feel less excluded—or, more accurately, more included but I am not looking for any of that. In truth, I do not care what you are, as long as you have something to teach. I am here to learn from you, and I hope you can learn something from me as well, whether in programming or in management.

I am, by far, the worst programmer I know, despite having “played around” with Delphi since 1997. On the other hand, it would be equally true to say that I am good at management. But none of that really matters. What matters is that, in this space, we are driven by curiosity, by that sudden urge to keep going without giving up, and by the desire to see things work.

Curiosity is the force that drives us. It makes us dig deeper, leaving behind beautiful memories and nurturing new friendships (perhaps lifelong ones) each person with their own strangeness and beauty, exactly as they are. Together, we are building a world that adapts to us.

Read more

Pablo Murad pablomurad.com

Movie: Napoleon Dynamite

The Art of Absolutely Nothing Happening

Napoleon Dynamite is a movie in which almost nothing happens - and, somehow, everything happens. There is no proper villain, no grand mission, nobody has to save the world, and no character stops the story to explain their trauma while staring through a rain-soaked window. There is just a teenager in moon boots walking around Preston, Idaho, looking like he was dragged out of a bad dream and told he has to present a group project.

That is exactly why I loved it. The film has the nerve to exist on its own frequency, a kind of small-town fever dream where every silence lasts half a second longer than it should, people talk with absolutely no sense of social rhythm, and a spoonful of mashed potatoes can carry more dramatic tension than the entire third act of most blockbusters. The humor does not come from traditional punchlines. It comes from the pause, the blank stare, the wrong outfit, the even-worse response, and the certainty that nobody in that town has the faintest idea how a human interaction is supposed to work.

It is deadpan elevated to a religion. The movie never begs for laughs, never uses the soundtrack to announce that something is funny, and never tries to win us over with artificially adorable characters. It simply shows Napoleon drawing a liger - "pretty much my favorite animal" - and moves on as though this were perfectly ordinary information. And maybe it is. After a few minutes, the movie's logic takes over. You stop wondering why its universe is so strange and begin to suspect that the real world is simply too conventional.

Napoleon is a walking masterpiece of awkward energy. He is irritating, arrogant, vulnerable, loyal, and completely incapable of understanding how other people see him. In a lazier high-school comedy, he would be the nerd who gets a makeover, learns how to use hair gel, and earns the approval of everyone who used to despise him. Not here. Napoleon remains Napoleon. There is no glow-up, no makeover montage, no speech about believing in yourself. There is only a boy who desperately wants everyone to know he has "nunchuck skills, bow hunting skills, computer hacking skills" - although the available evidence is, at best, inconclusive.

That is one of the sweetest things about the movie, even though it hides every trace of sentiment behind a bored expression. Napoleon does not need to become cool. Pedro does not need to become a charismatic candidate coached by consultants. Deb does not need to stop being shy. They only need to find some small way to exist together. The film looks at the weird kids without turning them into cute mascots, misunderstood geniuses, or moral lessons. They are strange, occasionally annoying, and frequently lost - and they still deserve friendship, dignity, and a place on the stage.

Pedro, by the way, is the true calm king of American cinema. While everyone else seems permanently one social interaction away from collapse, he moves through the film with the serenity of a man who has already realized that reality is far too ridiculous to justify anxiety. His campaign for class president is basically an anti-marketing performance: very few words, zero manufactured enthusiasm, and a slogan that entered pop culture because it cannot be improved. Vote for Pedro. That is it. That is the platform.

Uncle Rico and the Multiverse of 1982

If Napoleon is the teenager who has not yet found his place in the world, Uncle Rico is the adult who found his place in 1982 and refused to leave. He does not merely live in the past: he rented the past, furnished the past, and is probably trying to sell plastic containers to the past. Every time he appears, the movie gains another layer of discomfort. Rico has the confidence of a motivational speaker and the credibility of a man who records himself throwing a football beside a van.

His dream of traveling back in time to win a high-school football game is funny because it is absurd, but also because it is painfully recognizable. Everyone knows someone who turned one specific year of their youth into an entire personality. Uncle Rico is the patron saint of men who say, "I could have gone pro," while holding a warm beer at a barbecue. He permanently radiates "peaked in high school" energy, except for the minor detail that he may never have peaked at all. It is tragic, pathetic, and wonderful.

Kip, meanwhile, managed to become terminally online before it was recognized as a social diagnosis. He spends his days talking to women in chat rooms, discusses technology with the solemnity of a Silicon Valley pioneer, and somehow ends up in a genuinely sweet romance with LaFawnduh. The film could easily have used their relationship as nothing more than a cruel joke. Instead, it gives Kip perhaps the most sincere romantic arc in the entire story. In a universe where almost everyone seems emotionally frozen, the man who sings about technology finds somebody. Good for him, honestly.

Tater Tots, Glamour Shots, and an Aesthetic You Cannot Fake

Visually, Napoleon Dynamite looks like it was discovered inside a forgotten box in a basement, somewhere between a corporate training VHS and a 1987 school catalog. I mean that as a huge compliment. The faded colors, empty spaces, furniture, hairstyles, clothes, and endless Idaho sky create a place that seems trapped outside time. The film came out in 2004, but it could take place in 1986, 1994, or an alternate dimension where every store still sells binders with horses on the cover.

The direction understands that objects can tell jokes too. The tater tots hidden in Napoleon's pocket are not merely food; they are a manifesto. Deb's glamour shots, Uncle Rico's tapes, Pedro's bicycle, Tina the llama, the "delicious bass" - everything seems to have been selected by someone with an advanced degree in the science of embarrassment. Some movies spend millions building universes. Napoleon Dynamite needs only a corded telephone, a beige sofa, and a wolf T-shirt to establish an entire cosmology.

And then the dance happens. By that point, the movie has already proved it obeys none of the usual rules. But when Napoleon walks onto the stage and dances to Jamiroquai's "Canned Heat," the comedy becomes a strange little miracle. It is not technically perfect, nor should it be. It is awkward, intense, unexpected, and completely free. For the first time, Napoleon stops explaining his alleged skills and simply demonstrates one. The boy understood the assignment.

There is a huge difference between a comedy that humiliates its characters and a comedy that understands being human is already humiliating enough. Napoleon Dynamite almost always stays on the right side of that line. The movie laughs at its characters' social failures, of course, but rarely treats them with contempt. Even its most ridiculous people want things we can understand: Napoleon wants respect, Pedro wants to belong, Deb wants to be seen, Kip wants love, and Uncle Rico wants a time machine because accepting the present would be much harder.

Not everything will work for everyone. The story feels deliberately assembled from pieces somebody found scattered on the floor, the pace is so slow that some viewers will feel as though they are watching paint dry in Idaho, and several scenes end without the payoff a conventional comedy would promise. Anyone who needs plot twists, perfectly engineered character arcs, and a joke every fifteen seconds will probably hate it. Fair enough. But demanding conventional structure from this movie is like complaining that a liger does not exist in nature. You have completely missed the point.

For me, the pace was not an obstacle; it was the mechanism of the joke. I laughed so much because the film gives its strangeness room to breathe. Every silence becomes a trap. Every conversation sounds as though it was written by aliens who learned about humanity from high-school yearbooks. There is enormous precision behind the appearance of casual improvisation. The comedy looks effortless, but the timing is surgical.

Vote for Pedro - and for the Right to Stay Weird

In the end, Napoleon Dynamite is a comedy about people who do not know how to communicate, dress, flirt, or, in several cases, what they are doing at all. In other words, it is a movie about people. Its genius lies in turning small lives, monotonous routines, and social failures into something almost epic without ever abandoning the tone of someone answering, "Whatever I feel like I wanna do, gosh," when asked what they plan to do that day.

I finished the movie with that rare feeling of having visited an entire place. Not just a collection of characters, but a town, a temperature, a particular kind of carpet, an imaginary smell of a school cafeteria. It is extremely funny because it trusts the absurdity of everyday life and understands that the most memorable moments do not always arrive with a grand musical score. Sometimes they involve a student election, a drawing of a hybrid animal, a plate of nachos, or someone shouting at a llama to eat.

Napoleon Dynamite is weird cinema in its purest form: too specific to have been manufactured by committee, too sincere to be only ironic, and too funny to depend on nostalgia. More than twenty years later, it still feels like a creature nobody could reproduce in a laboratory. Thank goodness. Some things should remain unique.

VERDICT

9/10 tater tots hidden in a pocket

Gosh!

Read more

Pablo Murad pablomurad.com

How I Built Pablo's Room

I wanted a portfolio that felt like a place instead of a page. So I turned my workspace into a pixel-art scene: a cozy isometric office full of monitors, books, gadgets, a window with weather, and my cat, Netuno, napping somewhere in the frame. Every object in the room is clickable. Clicking opens a panel that tells a story — about a project, a tool I use, or just a detail of the room. The whole site behaves like a point-and-click adventure game, but underneath it is a modern, fully typed web application.

The Stack

•       React 19 + Next-style App Router, built and served through a Vite-based toolchain that compiles the app into a single edge-style worker bundle.

•       TypeScript everywhere — every data structure in the project has an explicit contract.

•       Tailwind CSS 4 plus hand-written CSS for the retro CRT/neon look, with two bundled terminal fonts (VT323 and IBM Plex Mono).

•       Framer Motion for motion, always gated behind reduced-motion preferences.

•       A serverless-style runtime: the production build runs as a worker on a lightweight local engine, with a SQL database for published content and an object store for uploaded media.

•       Docker for self-hosting, sitting behind my own reverse proxy and HTTPS domain.

There are no paid services in the loop. The same build can be deployed to an edge platform or run entirely on my own server.

One Coordinate System to Rule Everything

The heart of the project is a simple decision: the scene image and the interactive layer share the exact same coordinate space. The room illustration has a fixed logical size, and an SVG overlay uses that same size as its viewBox. Every clickable region — I call them hotspots — is defined in those image coordinates. Because the SVG scales with the image, hotspots stay perfectly aligned at any window size, on any device, in any orientation. No math at render time, no drift.

A hotspot is a small typed record: an id, a slug, a title, a shape (polygon, rectangle, circle or ellipse), its geometry, visibility flags, a category, tags, a z-index, and a content block. The whole room is one versioned JSON document with a schemaVersion field so future migrations stay possible.

The Content System

Each hotspot's content block can carry a short description, long-form Markdown, an image or gallery, video, audio, external links, metadata pairs, references to a small library of fictional books, and even custom commands for a fake in-page terminal. The terminal is a toy — it understands a handful of friendly commands and never touches the real system. Longer texts live as Markdown files, ready to be moved into a CMS later without changing any component.

Because content is data, not code, redecorating the room never requires touching a component. I edit JSON (or use the visual editor) and the site follows.

The Public Experience

The public page is composed of a few focused components:

•       RoomExperience — the orchestrator. It renders the scene, fetches the latest published content from the API at load time (falling back to the bundled document if the network fails), and manages which hotspot is active or open.

•       HotspotLayer — the SVG overlay. It draws each hotspot as a focusable, keyboard-operable shape with an accessible name, hover/focus highlighting, and an on-demand object list for people who prefer picking from a menu instead of hunting with a pointer.

•       InfoPanel — the storytelling surface. It renders the hotspot's Markdown and media, traps focus while open, closes on Escape or outside click, and becomes a bottom drawer on small screens.

•       Netuno — my cat, as a component. He is positioned in the same coordinate system as his hotspot, breathes, flicks his tail, and reacts when you pay attention to him. His animations can be disabled independently of everything else.

•       AmbientRoom — subtle life for the scene itself: glows, flickers and drifting effects anchored to the room's geometry, all disabled automatically when the visitor prefers reduced motion.

The title banner is a neon terminal prompt: a CRT-style font, a blinking cursor, layered neon glow, and an occasional sign-flicker — all pure CSS, all switched off for reduced-motion users.

The Visual Editor

Editing polygon coordinates by hand gets old fast, so the project includes a full visual editor. I can draw polygons vertex by vertex, drag out rectangles, circles and ellipses, move shapes, drag individual vertices, add or remove points, and edit every content field in a side panel. It supports undo/redo history, duplicating hotspots, copying and pasting hotspot JSON, previewing the public panel in place, and hiding, disabling or deleting areas with confirmation. Keyboard shortcuts cover the usual verbs — save, undo, duplicate, nudge by pixel.

Draft work autosaves locally in the browser. Import and export round-trip the same JSON document the site ships with, and imports are validated structurally before they are accepted — version, image dimensions, unique ids, shape geometry. A backup of the previous state is kept before any import.

Publishing and Administration

The public site is read-only. Writing goes through a small admin area with a login form. Authentication is intentionally boring and robust: credentials are checked server-side with constant-time comparison, and a successful login issues a signed, HTTP-only, strict-same-site session cookie with a short lifetime. The signature uses HMAC with a server-held secret, so sessions cannot be forged or extended from the outside. All secrets and credentials live in environment variables — none of them are in the repository.

Publishing takes the editor's current document, validates it again on the server (never trust the client, even when the client is me), and stores it in the database as the single published revision. Uploaded media — images, audio, the background-music tape — goes to the object store and is served back through a small asset route with proper content types and range support.

Storage

Published content lives in a tiny SQL schema: one table for the published site document (as JSON, with a timestamp) and one for upload metadata. The media files themselves live in an object store keyed by upload. The schema is created on demand, so a fresh volume boots into a working site with the bundled default content — the database is an overlay on top of the built-in room, never a requirement.

Keeping It Light

The production container is deliberately minimal. The application compiles into a self-contained bundle of about ten megabytes, so the runtime image installs only the worker engine — none of the build-time dependencies ship to production. The container starts a single small supervisor process plus the worker engine itself, idles at roughly 130 MB of RAM, and is capped with hard memory and CPU limits plus log rotation. Persistence uses the same on-disk layout as the standard tooling, so data survives upgrades and container rebuilds.

Accessibility

•       Every hotspot is a real focusable control with an accessible name, operable by Enter or Space.

•       An object list offers a precision-free way to open any item without pointer accuracy.

•       The info panel traps focus, restores it on close, and closes by Escape, button, or outside click.

•       Reduced motion is respected at three levels: the OS preference, a global animation toggle, and a separate toggle just for the cat.

•       Exploration progress (which objects you have visited) is stored locally and can be reset or turned off.

Quality

The project ships with a unit and component test suite covering the geometry utilities, document validation, editor history, authentication logic and the key components, plus strict TypeScript checking and linting. Production validation is a single ritual: build, test, lint, type-check. I also smoke-test the container itself — routes, login, static assets and storage layout — before calling a change done.

Serving It on My Domain

In production, the container binds only to the loopback interface and my reverse proxy terminates HTTPS for the public domain and forwards requests to it. The app is proxy-friendly by construction: no hardcoded hosts anywhere, relative URLs throughout, secure cookies in production, and absolute social-preview URLs generated from the canonical domain so shared links unfurl correctly.

What I Would Tell Past Me

•       Lock the coordinate system first. Every feature after that became easier because geometry was settled.

•       Make content data from day one. The editor, the API and the CMS-shaped future all fell out of that decision.

•       Validate at every boundary — imports, API writes, stored documents. Each validator earned its keep.

•       Treat the runtime as part of the design. Cutting the production container down to the essentials was as satisfying as any visual feature.

•       Put the cat in. People remember the cat.

Read more

Pablo Murad pablomurad.com

The Black Cat

How I Turned copyparty into a Tilde Without Shell Access

The idea, the architecture, and the building of a personal-web community on a server that was already alive


First Things First: Asking the Right Question

The idea began with a small, almost casual question: if copyparty can serve files, deliver an index.html, and apply different permissions per user and per directory, why couldn't I use it as the foundation for a tilde?

At first, the connection seemed slightly crooked. A traditional tilde is a shared Unix machine. A user receives an account, logs in over SSH, gets a home directory, works inside public_html, learns commands, runs programs, talks to other people, and gradually starts inhabiting the server rather than merely publishing through it. Tilde.club describes that tradition as access to a shared Unix computer where people create web pages, learn, and share knowledge.

copyparty, by contrast, presents itself as a portable file server: a browser interface, resumable uploads, WebDAV, SFTP, indexing, accounts, volumes, and directory-level permissions. It would have been easy to look at it and see nothing more than a strange, unusually capable Dropbox.

I saw something else: a publishing panel for the personal web.

The trick was not pretending that copyparty was a multi-user Unix system. It is not. The trick was separating tilde culture from one specific implementation. I wanted to preserve the parts that mattered to me: personal pages, /~username/ URLs, a small community, quotas, rules, a member directory, the freedom to edit HTML, and that old feeling that the web can still be made by hand.

I was willing to give up shell access in exchange for a much friendlier front door.

The Project's Thesis

A tilde does not have to lose its soul merely because access to public_html happens through a browser. I was not building a complete pubnix. I was building a deliberately limited webtilde: safer, more approachable, and centered on publishing.


What I Wanted to Preserve — and What I Chose Not to Imitate

Before installing anything, I had to be honest about the idea. If I called any public folder containing HTML a tilde, the word would become decoration. So I defined a minimum cultural core.

I wanted to preserve I would not try to reproduce
Personal pages at /~username/ Unix shell access for members
A small, recognizable community Persistent user processes
HTML, CSS, images, text, and small experiments Arbitrary compilers and runtimes
Quotas and human approval Open, automatic registration
A member directory and public rules Personal services and custom ports
Real files outside a CMS PHP, databases, or WordPress per member

That limitation was not a hidden defect. It was the product.

A member would receive static space, an editor account, a quota, and a URL. They would not receive access to Debian, Docker, Pangolin, Traefik, or any other part of the infrastructure.

That drastically reduced the attack surface and removed much of the thankless work involved in operating an open pubnix: fork bombs, cryptomining, abandoned processes, arbitrary listening ports, compilers, Unix permission puzzles, quota evasion, and CPU abuse.


Why copyparty Fit Better Than It First Appeared

copyparty had three characteristics that changed the entire discussion.

The first was its volume model: a URL can be mapped to a real directory, and each volume can receive permissions per user.

The second was the web interface. It could upload, rename, move, delete, and edit files without requiring members to understand SSH, SCP, Unix ownership, or command-line editors.

The third was the ability to treat a directory as a traditional website, serving index.html instead of exposing a file listing.

On top of that, copyparty already provided the unglamorous pieces I did not want to rebuild:

  • authentication;
  • password hashing;
  • resumable uploads;
  • per-volume storage limits;
  • maximum file counts;
  • maximum individual file sizes;
  • minimum free-disk reserves;
  • indexing;
  • account and volume reloads without bringing down the entire process.

The detail that made the project genuinely possible was realizing that the same files could be presented by two separate copyparty instances.

One instance would be an authenticated editor with write access.

The other would be an anonymous, read-only public server.

That split looks obvious after the fact. Before it, the project was merely a charming idea. After it, it became architecture.


The Server Was Not a Blank Slate

I did not begin with an empty VPS. The machine already mattered.

It was a Debian 13 virtual machine running under QEMU and serving as one of the central points in my infrastructure. Before the project, it had roughly 8 GiB of RAM and 150 GB of storage. I upgraded it to around 17 GiB of RAM, 1 TB of disk, and added 4 GiB of swap.

Storage stopped being the problem. The real risk became breaking what was already working.

The server already hosted several components:

  • Pangolin, Gerbil, Traefik, and CrowdSec under Docker Compose;
  • a Keila installation with PostgreSQL;
  • an FRP server running directly on the host;
  • Tailscale, SSH, SNMP, Exim, cron, and the usual Debian services;
  • ports 80 and 443 already owned by the Gerbil/Traefik path;
  • an external Docker network named pangolin, used by local services that needed to reach the proxy.

That imposed a rule that guided the entire build:

copyparty would not receive its own public host port.

There would be no 3923:3923 mapping followed by crossed fingers. The containers would remain invisible on the host and would be reachable only through the existing proxy on the existing Docker network.


The First Real Step Was Installing Nothing

Before creating the project, I audited the server.

I inventoried:

  • CPU;
  • memory and swap;
  • disks and filesystems;
  • mounts;
  • listening TCP and UDP ports;
  • systemd services and timers;
  • local users;
  • cron jobs;
  • firewall state;
  • Docker containers;
  • Docker networks;
  • volumes and bind mounts;
  • Compose projects;
  • logs;
  • the directories consuming the most storage.

That may sound excessively cautious, but it was the opposite. It was the fastest way to stop guessing.

The audit exposed an important topology. Traefik did not publish ports directly; it shared Gerbil's network namespace. Gerbil was the component actually holding ports 80 and 443.

It also showed that the existing projects relied on bind mounts rather than named Docker volumes, and that /srv was almost empty. That was where I decided to create /srv/theblack-cat as the isolated root of the experiment.

A Rule I Kept Afterwards

On a server with history, installing first and understanding later is the wrong order. An inventory is not bureaucracy. It tells you where a new service can exist without competing for ports, networks, storage, or authority with production systems.


The Architecture That Solved the Problem

The final design used two persistent copyparty instances, two web origins, and one content tree.

Internet
   |
   v
Traefik / HTTPS
   |
   +--> theblack.cat
   |       |
   |       +--> copyparty-public
   |               /w/sites      (read-only)
   |               /w/community  (read-only)
   |
   +--> edit.theblack.cat
           |
           +--> copyparty-editor
                   /w/sites      (read-write)
                   /w/community  (read-write)

The main domain became the display window. The edit subdomain became the file-management panel.

When I changed /community/index.html through the editor, that same file was already being read by the public instance. There was no deployment step, no publish button, no build pipeline, and no intermediate copy.

Saving was publishing.

At the same time, the public container mounted the shared directories with :ro. Even if an application flaw attempted a write, Docker's mount policy would block it.

That redundancy was intentional:

  • copyparty permissions at the application layer;
  • read-only mounts at the container/filesystem layer.

Foundation: A Service Identity and a Predictable Tree

I created a system user named theblackcat, with no password and /usr/sbin/nologin as its shell. It did not join the docker group and received no SSH access.

The pablo account would exist inside copyparty. It did not need to become another human Linux account.

/srv/theblack-cat/
├── config/
│   ├── editor/
│   └── public/
├── sites/
├── community/
├── members/
├── secrets/
├── state/
├── hists/
├── administration/
├── policies/
├── audit/
├── backup/
├── scripts/
└── release/

From the start, I treated configuration, content, runtime state, and secrets as different categories.

  • Configuration could be read by containers.
  • Session state needed to be writable, but isolated per instance.
  • Member sites were writable only in the editor.
  • Secrets used mode 0600.
  • The public service never received the authentication file.

That separation made later decisions much easier because each path already had a clear purpose and trust level.


Governance Before Registration

The system began as a closed alpha.

I deliberately did not start with a signup form, because receiving applications was not the difficult problem. The difficult problem was deciding what happened after someone applied.

So governance came before automation.

I established that every account and every quota required my explicit approval. The administrative account received 10 GiB because it would also be used for testing and for the community website.

Ordinary members could receive one of four approved allocations:

  • 100 MiB;
  • 250 MiB;
  • 500 MiB;
  • 1024 MiB.

The requested value would never become the approved value automatically.

I also set limits that made sense for personal websites:

  • 5,000 files;
  • 50 MiB per individual file;
  • a global reserve of 100 GiB free on the server.

The goal was never to host video libraries. The goal was to prevent a webtilde from accidentally becoming a file locker.


The Editor Configuration

Inside the editor instance, each member receives a dedicated volume.

In my case, /~pablo pointed to /w/sites/pablo. The administrative A permission remained exclusive to me. Ordinary members would receive rwmd, enough to read, upload, move, and delete their own files without receiving application-level administrative power.

[/~example]
  /w/sites/example

  accs:
    rwmd: example

  flags:
    e2ds
    nohtml
    xvol
    vmaxb: 250m
    vmaxn: 5k
    sz: 1-50m
    df: 100g

The nohtml flag was crucial.

The editor needed to manipulate HTML without executing a member's HTML inside the authenticated origin. With nohtml, HTML and SVG are returned as text, and related rendering behavior is restricted.

That distinction is subtle but important:

The place where I log in should not behave like the website a member is building.

The quotas use vmaxb and vmaxn. Because those controls depend on volume indexing, each volume also enables e2ds.

copyparty performs the accounting. I did not need to build another layer just to count files and bytes.


The Public Configuration

The public instance is almost the inverse.

It knows nothing about accounts and never receives the authentication secret. Each personal site receives only the h permission, which serves the directory as a traditional website and delivers index.html without turning the domain into a file browser.

[/~example]
  /w/sites/example

  accs:
    h: *

  flags:
    noscript
    xvol
    norobots
    cachectl: no-cache

During the alpha, I kept noscript enabled. That was not a religious position against JavaScript. It was a way to reduce variables while I validated the model. HTML and CSS were more than enough to prove the concept.

Later, the institutional community website received the root volume /, pointing to /w/community. The /~username volumes remained as child volumes and shadowed the root where appropriate.

The result was simple:

  • the community homepage lived at theblack.cat/;
  • personal pages remained at theblack.cat/~username/.

Containers: What I Explicitly Refused to Give Them

The image was pinned by digest rather than latest.

Both containers run as the UID and GID of the technical service account, with:

  • a read-only root filesystem;
  • all Linux capabilities dropped;
  • no-new-privileges enabled;
  • memory limits;
  • PID limits;
  • log rotation;
  • health checks;
  • no Docker socket;
  • no privileged mode;
  • no host networking;
  • no published host ports.

An abbreviated Compose example looks like this:

services:
  editor:
    image: copyparty/ac@sha256:EXAMPLE_DIGEST
    user: "999:986"
    read_only: true
    restart: unless-stopped
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 256
    mem_limit: 1g
    volumes:
      - ./sites:/w/sites:rw
      - ./community:/w/community:rw

  public:
    image: copyparty/ac@sha256:EXAMPLE_DIGEST
    user: "999:986"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    volumes:
      - ./sites:/w/sites:ro
      - ./community:/w/community:ro

networks:
  pangolin:
    external: true

The external network became the bridge between separate Compose projects.

Services could be resolved by name inside the pangolin network while the host continued to expose no listener on port 3923. That avoided creating a second public entry point or duplicating the proxy stack.


The Session-State Problem That Appeared Only Because I Did Things Properly

During isolated tests, copyparty warned that it had no safe writable location for session state.

/cfg was mounted read-only, and the service account did not have a useful writable home directory. The easy answer would have been enabling an unsafe option.

I chose to fix the cause instead.

I created separate state directories for the editor and public instances, mounted each one at /state, and set:

HOME=/state
XDG_CONFIG_HOME=/state/xdg

After that, the editor created a real session database. The warning disappeared without weakening the container.

This was a useful reminder that hardening is not simply a list of restrictions. A service still needs explicit writable locations for the state it legitimately owns.


Putting the Project Behind the Infrastructure That Already Existed

The first plan was to register both resources directly in Pangolin.

Discovery revealed an important detail: the available Pangolin sites represented remote tunnels. There was no local site capable of resolving containers on that same VPS.

Forcing the model would have produced a resource that looked elegant in the dashboard and failed in practice.

The solution was to use Traefik's file provider, already the established pattern for other local services on that machine.

I added two routers and two services while preserving:

  • HTTP-to-HTTPS redirection;
  • security headers;
  • automatic certificate issuance.

A simplified example:

http:
  routers:
    blackcat-public:
      rule: Host(`theblack.cat`)
      entryPoints:
        - websecure
      service: blackcat-public
      tls:
        certResolver: letsencrypt

    blackcat-editor:
      rule: Host(`edit.theblack.cat`)
      entryPoints:
        - websecure
      service: blackcat-editor
      tls:
        certResolver: letsencrypt

  services:
    blackcat-public:
      loadBalancer:
        servers:
          - url: http://theblackcat-public:3923

    blackcat-editor:
      loadBalancer:
        servers:
          - url: http://theblackcat-editor:3923

Public traffic reached Traefik, but copyparty remained invisible to the host. TLS terminated at the proxy, and the upstreams were addressed by Docker service name rather than by an exposed port.


The Real-IP Trap

That was when a bug appeared that exists only when security is partially correct.

Traefik sent X-Forwarded-For containing the visitor's real address, but copyparty did not trust the proxy. Because every request seemed to originate from the same private Docker address, the anti-abuse protection eventually banned the proxy itself.

The result was a 403 for everyone and the message:

thank you for playing

The investigation showed that Traefik shared Gerbil's network namespace.

Instead of trusting the entire Docker subnet, I allowed only the exact /32 of the observed proxy address. For illustration, imagine Gerbil at 172.20.0.4:

[global]
  xff-hdr: x-forwarded-for
  xff-src: 172.20.0.4/32
  rproxy: 1

rproxy: 1 documented that there was exactly one trusted proxy hop.

After restarting only the two copyparty containers, the proxy-wide ban disappeared. Logs began associating requests with the real visitor IP rather than with the Docker proxy address.

That fix came with an operational warning: the private address was dynamically assigned.

After recreating Gerbil or the Pangolin stack, I would need to verify the address again. The correct answer was not to trust 172.16.0.0/12 or the entire Docker network. The correct answer was to update the exact /32 to the newly observed proxy address.


I Did Not Want to Manage Users by Editing Files by Hand

At that point, the system worked, but it still depended on manual changes to YAML, authentication fragments, and volume configuration files.

That would have been tolerable for me alone and terrible for a community.

The next step was building an administrative CLI: theblackcat-admin.

The tool implemented an explicit lifecycle:

pending
  ├── approved
  │     └── active
  │           ├── suspended ──> active
  │           └── archived
  └── rejected

Each mutating operation uses:

  • a lock;
  • a backup;
  • atomic writes;
  • validation;
  • rollback;
  • an audit event written as JSON Lines.

The tool never accepts a plaintext password. The administrator generates a hash interactively with copyparty's own utility and installs only the resulting value.

A normal onboarding sequence looks like this:

theblackcat-admin candidate-add alice \
  --display-name "Alice" \
  --email "alice@example.net" \
  --requested-quota 250

theblackcat-admin approve alice \
  --quota 250 \
  --approved-by pablo

theblackcat-admin credential-instructions alice

theblackcat-admin credential-install alice

theblackcat-admin activate alice \
  --approved-by pablo

Activating a member creates:

  • the member directory;
  • the initial homepage;
  • the editor volume;
  • the public volume;
  • the authentication entry;
  • the public directory listing entry.

It then reloads accounts and volumes and validates the result.

Suspending a member performs the inverse without deleting anything. Archiving removes publication and authentication while preserving files.

I deliberately did not implement deletion. Removing data should be a separate, reviewed decision, not an accidental side effect of moderation.


Reloading Without Restarting the Service

Accounts and volumes are reloaded with USR1:

docker kill --signal=USR1 theblackcat-editor
docker kill --signal=USR1 theblackcat-public

This mattered because a member could be activated or suspended without recreating containers and without interrupting existing pages.

The CLI records StartedAt, sends the signal, checks container health, and confirms that the process did not restart.

Changes to the global configuration still require a restart, and I treated them as a separate class of operation. The ordinary member lifecycle does not touch global settings.


The Community Became More Than a Single /~pablo/

The final public layer was the community website itself.

I created:

  • a homepage;
  • an About page;
  • a Members page;
  • a Rules page;
  • an Alpha Status page.

Everything uses local HTML and CSS, with no JavaScript, remote fonts, analytics, or trackers. robots.txt blocks indexing, and responses also carry noindex instructions.

The member page is not maintained by hand. The CLI generates it from protected member records and exposes only:

  • username;
  • public display name;
  • public URL;
  • a safe role label.

Email, quota, internal notes, suspension reasons, and timestamps never enter the public HTML.

Pablo appears as the founding administrator. An ordinary member appears only when their status is exactly active. Suspending or archiving someone regenerates the directory within the same transaction.


Backup: The Part That Never Looks Impressive in a Screenshot

I used Restic to create encrypted, deduplicated, testable snapshots.

The initial repository remained on the same virtual disk, outside the project tree. That provides rollback and recovery from human mistakes, but it is not disaster recovery.

I wrote that limitation in large letters because calling a same-disk copy an offsite backup would be dishonest.

The system received:

  • daily snapshots;
  • weekly maintenance;
  • 7 daily snapshots retained;
  • 4 weekly snapshots retained;
  • 6 monthly snapshots retained;
  • 1 yearly snapshot retained.

Maintenance runs forget with prune, followed by a repository check:

restic forget \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  --keep-yearly 1 \
  --prune

restic check --read-data-subset=10%

I also wrote a real restore test. It restores the latest snapshot into a temporary directory, validates critical files, YAML, JSONL, Compose configuration, and permissions, securely removes restored secret files, and deletes the temporary tree.

The repository password lives outside the project in a root-only file.

Restic's encryption is valuable precisely because the password matters. Losing both the original machine and that password means losing access to the repository. That is why two operational tasks remain important:

  1. export the recovery key to a secure offline location;
  2. configure a genuinely independent offsite destination.

What the System Became

At the end of the ten-part build, I had something that did not exist as an off-the-shelf product: a copyparty-based webtilde with a community homepage, personal pages, an authenticated editor, read-only publication, quotas, moderation, auditing, backup, and a tested restore procedure.

Layer Function
theblack.cat Public site, community pages, and /~username/
edit.theblack.cat Authenticated file management
copyparty-editor Write access, quotas, nohtml, and account volumes
copyparty-public index.html, h: *, noscript, and read-only mounts
Traefik HTTPS, redirection, and routing
theblackcat-admin Approval, activation, suspension, quota management, and auditing
Restic + systemd Snapshots, retention, integrity checks, and restore tests

The result is not a pubnix.

It does not offer SSH to members, personal cron jobs, compilers, or long-running processes. I do not hide that.

At the same time, calling it simple static hosting would also miss the point. It has a community, an identity, a URL structure, rules, limits, lifecycle management, and a shared publishing experience.


Where the Real Cleverness Was

The merit of the idea is not hidden in an obscure configuration line.

The clever part was realizing that a file server could occupy the role of a publishing panel, as long as I did not ask one single instance to act as both editor and public server.

Separating those responsibilities solved almost everything:

  • the editor could require authentication and refuse to execute member HTML;
  • the public service could deliver index.html without knowing any credentials;
  • the same files appeared on both sides without a deployment stage;
  • Docker enforced read-only publication in addition to application permissions;
  • copyparty's volume model naturally became a /~username/ directory structure;
  • native quotas removed the need for a separate accounting system.

From that point on, the rest was edge engineering: integrating the proxy, trusting the correct client address, creating an administrative workflow, testing rollback, and documenting limitations honestly.

The idea opened the door. Rigor kept it from becoming a fragile toy.


The Decisions I Do Not Romanticize

Some choices remain compromises.

The local backup is on the same disk and still needs an offsite copy.

The administrative credential used during the build was exposed in conversation and must be rotated before opening invitations to outsiders.

The trusted private proxy address is dynamically assigned and must be revalidated after the stack is recreated.

Those pending items do not invalidate the project. They define its real state.

The Black Cat became operational in owner-only mode. I could edit, publish, test, and administer it. I still should not invite third parties until the credential is rotated.

That operational honesty is worth more than an artificial green badge.


What I Learned While Building It

  1. The best architecture appeared when I stopped trying to make one instance perform contradictory roles.
  2. On existing infrastructure, the initial audit saves more time than any automatic installer.
  3. Read-only at the application layer is not the same as read-only at the container layer; using both is better.
  4. Quotas without governance merely limit bytes. Human approval limits purpose.
  5. A badly configured proxy can turn anti-abuse protection into global downtime.
  6. Administrative automation needs transactions, locks, validation, and rollback even when the project is small.
  7. A backup deserves trust only after a restore has been tested.
  8. A tilde is as much a culture of publishing as it is a Unix machine. Part of that culture can survive without pretending shell access exists.

Conclusion

I began by asking whether copyparty could host simple websites.

I ended with a closed community for personal pages, built on top of a server that already did many important things and could not be treated as a disposable laboratory.

The most satisfying part was seeing that the idea remained simple even after it gained serious security and operational layers.

For a member, the experience is straightforward:

  1. log in to the editor;
  2. open their directory;
  3. change index.html;
  4. see the page at /~username/.

Underneath that simple experience are separate containers, permissions, quotas, a reverse proxy, TLS, real-client-IP handling, auditing, backups, and restore tests.

The complexity ended up on the correct side of the door.

The Black Cat does not try to replace a traditional Unix tilde. It is another interpretation: a webtilde for people who want to build their own corner of the internet without beginning with SSH.

It was a genuinely good idea because it used exactly what copyparty does best — files, accounts, and volumes — to create something the project never explicitly promised to be, but for which it was surprisingly well prepared.

Read more

Pablo Murad pablomurad.com

How I built NyxPE

I do a lot of tech work. Reinstalls, data recovery, dead boot loaders, the usual. And for years I did all of it from other people's rescue disks, mostly Sergei Strelec's WinPE. Nothing wrong with it, honestly, it's a fantastic piece of work. But every single boot I'd be re-fixing the keyboard layout, hunting for a tool three submenus deep, staring at branding that wasn't mine. Small itch, never went away. So I finally sat down and built the thing I actually wanted, and that's NyxPE. Nyx Rescue Environment.

The base was an easy call: PhoenixPE plus PEBakery. PhoenixPE gives you a clean, scriptable WinPE project, and PEBakery is the engine that turns a pile of scripts into a bootable image. The one rule I set for myself on day one was to never touch the official PhoenixPE scripts. Everything I add lives in my own folder. That way, if I ever want to pull a newer PhoenixPE, I just drop it in and my stuff still sits on top. A little discipline now, a lot of sanity later.

Making it feel like mine

First thing was identity, because if it doesn't look like NyxPE it's just PhoenixPE with extra steps. Dark wallpaper, a moon logo, the "NYX PE" wordmark, a matching splash while it boots. OEM info so the System Properties page says NyxPE instead of the default. And the computer name, which turned out to be weirdly annoying. WinPE loves to call the machine something like PHOENIXPE-VF23 with a random suffix, and after some digging I found the culprit was PENetwork, generating the name from a profile template. So I nailed it in two places: offline in the registry hives during the build, and then again at runtime with a tiny script that fires after the network comes up, because the network stack will happily rename the box out from under you if you let it.

PENetwork itself needed to shut up. Out of the box it throws a window in your face on boot, runs a countdown, asks questions. I just want ethernet up on DHCP and the icon sitting quietly in the tray for later. So I went through the actual INI keys the bundled version uses (I refused to copy-paste settings off some 2011 forum post and hope for the best), and now it starts silent, grabs an address, and gets out of the way.

Little things after that. Firefox with exactly one bookmark, my GitHub, nothing else. Killing the Windows boot logo so you get a clean black screen instead of the spinning dots. I did that one with bootuxdisabled in the BCD, no patching of signed files, nothing sketchy with Secure Boot.

The part that ate my weekend

Then I went big. I wanted a real toolbox: backup, imaging, partitioning, file recovery, read-only forensics, malware scanners, network tools, hardware diagnostics. Dozens of programs.

The catch is the build has to stay offline. I didn't want scripts phoning home in the middle of a build. So I wrapped a little package system around it. A manifest of every app with its official source, a downloader that pulls each one straight from the vendor or the GitHub release, checks the SHA-256 and the Authenticode signature, and stages it locally. Then the PEBakery build just copies from the local stash. If a tool goes missing, the build warns and keeps going instead of blowing up.

The audit had a nice surprise: PhoenixPE already ships scripts for a ton of apps. So the smart move wasn't to reinvent those, it was to keep the good official ones and only write my own for what was missing. It also forced a decision I feel pretty strongly about. PhoenixPE bundles a whole family of credential-dumping tools, browser password grabbers, LSA secrets, all of that. I pulled every single one out of my profiles. A rescue disk is for fixing your own machines, not lifting passwords off them. The only account tool I kept is a proper local admin reset that warns you, backs up the registry hives first, and never runs silently.

Bugs. So many bugs.

This is where it got real. A few that stuck with me.

The one that made me feel dumb: my downloader kept staging exactly one app and marking every other one "no resolver defined". Turns out PowerShell variable names are case-insensitive, so $res (my per-app result) and $RES (my big lookup table) were literally the same variable. The first loop clobbered the table and the rest just found nothing. Renamed it, fixed instantly, said a few words out loud.

Then there was a helper function I named H. PowerShell has a built-in alias h for Get-History, and aliases win over functions, so every call to my H was quietly running Get-History and choking on a category name it was never meant to see. Renamed that too.

The nasty one: every app installed fine, and every single shortcut was dead. "The item has been changed or moved." Took me a while to trace. I was using DirCopy with a wildcard to drop each program into place, and it turns out DirCopy only grabs the subfolders, not the files sitting in the root. So the mappings folder made it onto the media and the actual .exe never did. Every shortcut pointed at a file that wasn't there. Swapped in FileCopy and DirCopy together and suddenly everything opened.

And the one that nearly ended me. The build would finish "successfully" and produce no ISO. The imaging tool was dying instantly. When I finally ran it by hand it told me the truth: one of the Eric Zimmerman forensic tools has map files with names over 120 characters, and oscdimg in the default ISO9660 mode caps paths around 110. GSmartControl's GTK icons blew past it too. I renamed files for a while, playing whack-a-mole, before doing the real fix, which was to switch the image to pure UDF. No path limit, and it's what every modern WinPE build uses anyway. Booted BIOS and UEFI on the first try after that.

Smaller ones too. A .NET tool with no version resource killed the whole build, because I was reading its file version for a marker file. Wrapped it so a missing version is just "n/a" instead of a hard stop.

Once it actually worked, I started caring about the feel. Command-line tools were the worst. You click one in the Start Menu and a console flashes for a tenth of a second and vanishes, because it's waiting for arguments. So now the CLI tools open a prompt already parked in their own folder, wearing the tool's own icon, ready to type. GUI apps open normally. Sysinternals gets split the same way, since half of it is windows and half is command line.

I also dropped a usage guide right on the desktop. An HTML page that lists every tool, tells you whether it's a window or a terminal thing, and gives a quick example for the command-line ones. Because future me, at 3am with a dead laptop, does not remember the exact chainsaw syntax.

All of the testing has been in VMware, watching the splash come up, the desktop load, the network grab an address, the tray behave itself. Seeing NYXPE on that wallpaper for the first time, after all the fighting, was genuinely a good moment.

It lives at nyxpe.xyz now. The ISO, the guide, a checksum, and a short honest note about licensing, because it's built on Windows PE and a mix of third-party tools, and I'd rather be upfront that this is a personal, for-study project than pretend it's something it isn't. Next I want to swap in a real graphic boot splash and start wiring up separate rescue ISOs for iVentoy, but that's a problem for another weekend.

Read more

Pablo Murad pablomurad.com

The Terminal Is a Door

For a while now my writing has lived in more than one place. There are posts on Prose. There is a Gemini capsule at pablomurad.com. There is a Gopher hole for whoever still remembers how to knock. Same words, different doors — I have always liked the idea that a single text can be reached in many ways, each with its own texture, its own weather.

This week I added another door. Not a new post, not a new feed: a whole new way in. I built a BBS.

If you were online in the right decade, the word carries a smell — dial tones, ANSI art, the patience of a modem. A Bulletin Board System was a place you called, not a page you scrolled. You arrived. You typed. The screen answered in blocks of colour. For me the appeal was never nostalgia; it was the ritual. The web became fast and smooth and tracked. A terminal asks something of you. It slows you down. It gives texture back.

So now, alongside Prose, Gemini and Gopher, there is bbs.pablomurad.com.

How you get in

Two ways, both from a terminal:

ssh guest@bbs.pablomurad.com -p 2200

telnet bbs.pablomurad.com 4223

No password, no account. The user does not matter — guest, your name, anything. You are not given a shell; you are given the archive. Over SSH you get the full thing: colour, ANSI art, a block-letter MURAD sign at the top like a marquee. Over Telnet you get the same in CP437, the old DOS character set, which is the honest way to serve a BBS to a telnet client — and, I will admit, the more beautiful one.

Inside, you move by typing. ls lists everything. open reads a text. search looks through titles and bodies. random hands you something at random, which is my favourite way to meet an old piece again. Long texts turn into pages you step through. back takes you where you came from. It is small and it is quiet, and that is the point.

One archive, many doors

The part I care about most is not the ANSI. It is that none of this is a copy.

Everything I write lives once, as plain text, in a single place. It is served by one small program written in Go. From that one source the BBS reads its pages; from the same source I generate the Gemini and Gopher versions of the same texts; and the BBS, in turn, reads my gemlog — the very posts I write for the capsule — so the blog is legible from inside the terminal too. Write once; appear everywhere. A post on Prose, an entry on Gemini, a menu in Gopher, and now a room in a BBS, all pointing at the same words.

That is the whole philosophy, and I keep it short enough to fit on a screen:

Web for reach. Terminal for ritual.

Markdown for preservation. ANSI for soul.

Why bother

Because the small web is not a museum. It is a way of publishing that refuses a few things — surveillance, bloat, the assumption that faster is better — and keeps a few others: ownership, permanence, calm. A BBS is the most stubborn form of that refusal I could find. It does not care about your browser. It will not autoplay anything. It waits for you to type.

There is also a plainer reason. It made me happy to build it. To watch a modem-era screen come up over SSH, to see accented Portuguese survive a trip through CP437, to type open and have my own words page up in colour — that is a small, real pleasure, and I have decided those are worth chasing.

So: I still post on Prose. The capsule is still on Gemini. The hole is still on Gopher. And now there is a light on in the terminal too.

Come knock.  ssh guest@bbs.pablomurad.com -p 2200

Read more

Pablo Murad pablomurad.com

Building Runv Club

After never getting a reply from a few tildes, which honestly made me sad because I tried more than once and waited more than once, I decided to build my own pubnix.

Just to make things clearer, a pubnix is a shared Unix or Linux server, usually accessed through SSH, where people have their own user accounts. They can host personal pages, learn the terminal, write code, exchange messages, use email, run small services, and take part in a more handmade kind of internet culture. I like to think of it as a small digital condominium, built around community, experimentation, the small web, and autonomy. Very far from the closed, polished, locked-down logic of the big platforms.

The project slowly became something close to an obsession. From the outside, I started studying every pubnix I could find. I went from SDF to Tilde Town, one by one, looking at their code, their architecture, their habits, and the way they handled administration. Not because I wanted to copy them, but because I wanted to understand how these places actually worked, especially behind the scenes.

One curious thing I noticed is that many pubnixes are built on the BSD family.

I could understand the appeal. BSD is stable, elegant, and maybe even a little sophisticated. But beyond that, I did not find a strong reason why I could not use another operating system.

So I thought: fine, I will use Debian.

Another thing I noticed is that a lot of their administrative tools are written in C, and some in Go. I decided to go in another direction and build around Python and its ecosystem. That was the path I wanted to follow. Python would help me administer the place, glue things together, automate the boring parts, and keep the whole thing readable enough for me to maintain.

The basic idea behind Runv is simple: each member can publish personal content through four different protocols, each one mapped to a folder inside their home directory.

HTTP is served by Apache 2 with mod_userdir, on ports 80 and 443, using ~/public_html. Each member gets a URL like /~username/, with TLS handled through Certbot.

Gopher is served by Gophernicus on port 70, using ~/public_gopher, with a gophermap and /var/gopher as the root.

Gemini is served by Molly Brown on port 1965, using ~/public_gemini. Each user directory is exposed through a bind mount at /var/gemini/users/<user>, with TLS certificates from Let's Encrypt.

Nex is served by my own small nexd, written with the Python standard library, on port 1900, using ~/public_nex. No bind mount, no complexity, just plain text and a very small protocol doing exactly what it needs to do.

The base stack is Debian, Apache 2 with mod_userdir, mod_rewrite, mod_headers, and mod_proxy, OpenSSH, systemd, ext4 quotas with usrquota, Certbot, and Let's Encrypt. Transactional email can go through Mailgun over HTTP or through sendmail/msmtp, while member email is handled through Postfix. For IRC, the default path is WeeChat on irc.tilde.chat, especially the #runv channel.

After writing the first scripts, I started to see where the project was going. It was no longer just an idea. It was taking shape in front of me. But something important was still missing: the entrance. The welcome. The first contact.

So I built the onboarding flow around a special account called entre.

New people do not get automatic invites. Entry is manually approved. A visitor connects through SSH as entre@runv.club, and sshd uses ForceCommand to run entre_app.py. That application only queues the request. It never creates accounts by itself.

The flow is intentionally simple.

First, the visitor chooses a language: Portuguese or English. That choice follows the whole session through the i18n system.

Then Runv shows the ASCII art and the introduction and warning templates.

After that, entre_core.py validates the request. It checks the username with the pattern ^[a-z][a-z0-9_-]{1,31}$, blocks reserved names, validates the email, asks for online presence, and checks the public SSH key. Only allowed key types are accepted, and the fingerprint is generated through ssh-keygen.

The request is then written as an atomic JSON file, using O_EXCL, into /var/lib/runv/entre-queue/<uuid>.json, including the selected language.

Finally, the admin is notified through logs and email, and the visitor sees a goodbye screen with the request number.

The visitor-facing strings live in terminal/i18n.py, with Portuguese and English parity enforced by tests/test_i18n_strings.py. The admin templates are intentionally only in Portuguese, because that part is for me.

At that point, another question appeared: how would I give people accounts without exposing things they should not see? How could I keep the privacy principle alive while still giving users a real shell environment?

That led me to the account provisioning system.

The canonical way to create accounts is scripts/admin/create_runv_user.py. It runs as root and is protected by admin_guard. The operator has to be pmurad-admin, root, or someone listed in RUNV_ADMIN_USERS.

The pipeline follows a strict order.

First, the script creates the user with adduser --disabled-password, copying Debian's default skeleton.

Then it installs the SSH key with the right permissions: ~/.ssh as 700 and authorized_keys as 600.

After that, it creates the public folders: public_html with an index.html, public_gopher with a gophermap, public_gemini with an index.gmi and the Gemini bind mount, and public_nex with an index.

Then it consolidates permissions: home directories as 755, public folders as 755, and files as 644.

There is also an optional legacy SSH jail mode through --with-jail, but by default members are not jailed, because I want them to be able to use the global commands.

The script applies ext4 quotas using setquota, with the default being 450/500 MiB and 10k/12k inodes.

Then it runs a final permission check, applies the IRC patch that enables the chat command, updates users.json using flock, syncs the landing page with genlanding --sync-public-only, and sends the welcome email and the admin notification.

If something fails after the user is created, the script rolls back with deluser --remove-home. If only the quota step fails, the account is left in a partial_quota state, so I know exactly what needs attention instead of pretending everything is fine.

Approving requests from the queue is also straightforward.

To approve a specific request:

sudo python3 scripts/admin/create_runv_user.py --request-id <uuid>

To approve all pending requests:

sudo python3 scripts/admin/create_runv_user.py --all-pending

And to create a user manually, without the queue:

sudo python3 scripts/admin/create_runv_user.py --username maria \
     --email maria@example.org --public-key-file maria.pub

Building Runv Club has been genuinely fun. Not fake startup fun, not polished product-launch fun, but real fun. The kind where you break things, understand why they broke, fix them, and suddenly realize you learned more in a few nights than you would have learned from weeks of passive reading.

It is still small, still imperfect, and still becoming what it wants to be. But it already feels alive.

https://runv.club

Read more

Pablo Murad pablomurad.com

One Post, Many Webs

A while ago I wrote about the little bridge that keeps my two blogs in sync — Ghost as the canonical home, prose.sh as a plain-text mirror, and even a way to publish by sending an email. One post, two blogs, zero copy-paste.

That should have been the end of the story.

It wasn't, because I remembered I also had a Gemini capsule and a gopher hole quietly rotting somewhere, each with content from two eras ago. And once you have a bridge that reacts to "post published" and delivers things over SSH... well. You know how this goes.

This is part two: how one webhook ended up feeding the entire small web, two fediverse accounts, Bluesky, and every site I link to. Same rule as before — one source of truth, one road out.

The small web speaks differently

Gemini and gopher aren't just "small HTTP". They have opinions.

Gemtext, Gemini's format, is radical: no inline links, no inline formatting. A link gets its own line, starting with =>. So you can't just dump Markdown there — every [link](url) in the middle of a sentence has to go somewhere.

The community convention is to hoist links out of the paragraph and list them right below it. So the converter reads a paragraph, strips the link syntax, remembers what it found, and flushes it after:

markdown in:
  Last year I [switched ISPs](https://example.com/isp) and lost my public IP.

gemtext out:
  Last year I switched ISPs and lost my public IP.
  => https://example.com/isp switched ISPs

Gopher is even older-school: plain text, and the unwritten rule says keep it under ~67 columns because that's what classic clients render comfortably. Links become numbered footnotes, like a scientific paper written on a napkin:

gopher out:
  Last year I switched ISPs [1] and lost my public IP.

  Links:
    [1] https://example.com/isp

The whole converter is one pass over the Markdown, line by line, with two renderers at the end. No AST, no dependency with 400 transitive packages. Gemtext is simple enough that respecting it is easier than fighting it.

Ghost is the manifest

Here's the design decision I'm most happy about, and it costs nothing: the bridge keeps no database of posts. None.

Every time a post is published, the bridge asks Ghost's Admin API for the full list of published posts and regenerates the indexes — the gemlog index, the gopher menu, the Atom feed — from scratch:

on publish:
  1. deliver the new post        (slug.gmi, slug.txt)
  2. fetch ALL published posts   (Ghost Admin API, paginated)
  3. rebuild every index from that list
  4. deliver the indexes

Stateless. Always correct. If an index ever looks wrong, restarting the service fixes it, because there's nothing to corrupt — Ghost is the manifest.

And you get backfill for free: populating years of existing posts into a freshly wiped capsule is the same code path, just looped. One flag, one restart, the whole archive appears on gemini and gopher. Delete the flag, done.

Pagination, because archives grow

A gemlog index with the whole archive on one page is fine at 10 posts and absurd at 200. So indexes paginate — twenty per page, with newer/older navigation:

gemlog/index.gmi        <- newest twenty
gemlog/page-2.gmi       <- next twenty
gemlog/page-3.gmi       ...

phlog/gophermap         <- newest twenty
phlog/page2/gophermap   <- next twenty

Since indexes are rebuilt from zero every time, stale pages get deleted before writing. If posts disappear, pages disappear. No ghosts. (Well. Except the canonical one.)

Gopher's dirty little secret

Here's a protocol fact that cost me an evening: gopher requests don't include the hostname. No Host header, nothing. The client connects to an IP and sends a selector, and that's it.

Which means: if two domains point at the same server, port 70 cannot tell them apart. Name-based virtual hosting, the thing HTTP has had since the 90s, is physically impossible in gopher.

I had an existing gopher site on another domain, same box. The fix is beautifully dumb: the new hole owns the root, and the old site survives as symlinks inside it, so every old deep link still resolves:

/var/gopher/
  gophermap            <- new home (the menu controls what's visible)
  phlog/
  oldsite/     -> /home/oldsite/gopher     (branding link)
  archive/     -> /home/oldsite/gopher/archive   (compat: old selectors)

The menu shows what I want; the symlinks keep two decades of gopher etiquette intact. Everyone's links still work, nobody notices anything changed. That's the best kind of migration.

Homepages worth the trip

If someone bothers to open a Gemini client or a gopher browser in 2026, the least I can do is greet them properly. Both homepages got rebuilt from scratch: a FIGlet banner, a piece of classic ASCII art (credited — small web, good manners), and short English copy.

The static files are baked into the container image and pushed on startup, so "redeploy the homepage" is just "rebuild the container". The blog sections underneath them are regenerated by the bridge, so the homes never go stale.

Retiring the feed robot

For social announcements I used to run my posts through a hosted RSS-to-everywhere service. It worked, but it's polling — the announcement shows up whenever the feed gets read next — and it's one more account, one more dashboard, one more thing.

The bridge already knows the instant a post goes live. So it now announces natively:

  • Mastodon-compatible instances (including GoToSocial): one POST /api/v1/statuses with a Bearer token. The post's feature image gets downloaded and attached as media, with the title as alt text.
  • Bluesky: a session, an optional thumbnail blob, and a post whose link lives in an external embed card — so the URL doesn't eat into the 300-character budget, and you get the pretty preview.

The announcement text writes itself from what the post already has: an excerpt if I wrote one, otherwise the first lines of the content. And instead of a robotic "New post:", the opener rotates — deterministically, hashed from the slug, so retries don't reroll it:

openers = [ "Hot off the press:", "Fresh from the lab:",
            "Straight from the terminal:", "Just shipped:",
            "New transmission:" ]

pick = hash(slug) % len(openers)

Same post, same opener, forever. Different posts, different flavor. Nobody suspects a robot.

The double-post problem

Mirrors are forgiving: deliver the same file twice and you've overwritten it with itself. Social is not. Every announcement is a new item in someone's timeline, and re-announcing a typo fix is how you teach people to unfollow you.

Two layers fix it:

One — split the events. Ghost fires webhooks for "post published" and "published post updated". Give each its own query string:

.../hooks/ghost?event=published   -> mirrors + announce + webmentions
.../hooks/ghost?event=updated     -> mirrors + webmentions. NEVER announce.

Editing a post updates every mirror and stays silent on social. Exactly what you want.

Two — keep a ledger. A tiny JSON file in a persistent volume records which slugs have been announced:

["my-first-post", "that-networking-rant", "one-post-many-webs"]

Webhook retries, container restarts, full backfills — none of them can announce twice, because the slug is already in the book. The ledger only gets written when at least one network accepted the announcement, so a total outage retries cleanly next time.

Belt, suspenders. Timelines unspammed.

Webmentions: closing the loop

The last piece is the most indieweb thing of all. When a post links to someone's site, the polite move is to tell them — that's a webmention. On publish (and update — receivers dedupe, it's in the spec), the bridge:

  1. extracts every external link from the post,
  2. asks each target "do you accept webmentions?" — an HTTP Link header or a rel="webmention" tag in the HTML,
  3. and if yes, sends a tiny form: source=my post, target=your page.
POST https://example.com/webmention-endpoint
Content-Type: application/x-www-form-urlencoded

source=https://myblog.example/one-post-many-webs/
&target=https://example.com/the-page-i-linked

It runs in the background after the webhook responds, with timeouts, skipping my own domains, capped at a sane number of targets. Most sites don't accept webmentions. The ones that do get a knock on the door.

What the flow looks like now

 write in Ghost ── or send an email ──┐
                                       │  one webhook
                                       ▼
                              ┌─────────────────┐
                              │      bridge     │
                              └────────┬────────┘
        ┌──────────┬──────────┬────────┼─────────┬──────────┐
        ▼          ▼          ▼        ▼         ▼          ▼
     prose.sh   gemini://  gopher://  fediverse  Bluesky  webmentions
     (markdown) (gemtext)  (67 cols)  (2 accts)  (card)   (to whoever
                                                            I linked)

One post. Seven destinations. Zero extra effort at publish time — the effort was spent once, building the pipes.

Lessons from part two

  • Statelessness is a feature you choose. Making the CMS the single manifest deleted a whole class of bugs (and the database that would've hosted them).
  • Respect each medium's grammar. Gemtext without inline links, gopher at 67 columns, Bluesky's 300 characters. Converting properly beats mirroring lazily.
  • Separate "changed" from "new". Mirrors want both events; announcements want exactly one. A query string was all it took.
  • Idempotency needs memory only where actions aren't reversible. Mirrors overwrite; social needs the ledger. Put state where it's mandatory, nowhere else.
  • Old protocols deserve real engineering. The gopher hostname problem is unsolvable in-protocol — but a menu plus symlinks made the migration invisible. Constraints breed elegant hacks.

The bridge started as "I'm tired of running scp twice." It's now a tiny syndication hub that speaks five protocols, and I still just click Publish.

Boring magic, compounding.

And yes. It's still working.

See you around.

Read more

Pablo Murad pablomurad.com

Being the CEO.

It’s all part of a process. It’s harder than it looks, and when people look from the outside, they only see the good parts.

I remember that about two years ago, I had to hire an executive for the company, and it turned out to be an extremely interesting experience with the two candidates. There was something they both had in common, and I suspected that if there had been a third candidate, he probably would have shared the same peculiarity: they both seemed to be deeply obsessed with power.

Doing a good job didn’t seem to matter, as long as they were able to subjugate others.

Neither of them lasted more than a few days at the company. They didn’t know how to handle the weight of their decisions, and being an executive is, more than anything, exactly that. It means making decisions that will impact dozens of people, taking on the risk and the responsibility — something most people avoid.

They had impeccable résumés, but they lacked the maturity required for the role.

I’m briefly telling this story because this week I had to make a move that was considered impossible and extremely risky, one I don’t want to talk about right now. And if it goes wrong, the only person to blame will be me.

Sleeping with that responsibility can be difficult, and it already hurts.

It’s as if you were in control of fate, but you’re not. The only thing you have is faith that everything will work out in the end.

And that’s it.

Read more

Pablo Murad pablomurad.com

Amon: how I made Fedora feel right on the MSI Pulse 16 AI

I wiped the notebook and installed Fedora KDE because I wanted a fast, clean and current machine. The idea was not to turn the installation into an eternal lab project. I wanted it ready for work, writing, coding, server access, IRC, cloud storage and full use of the hardware without fighting drivers all day.

The machine was named Amon. The goal was simple: install the system, get the NVIDIA driver right, build a strong writing environment, make SSH and Tailscale work, configure power behavior without annoying surprises and customize KDE without filling the system with questionable themes.

This text does not include passwords, private domains, IP addresses, tokens or sensitive details. Commands only appear when they help document what was actually done.

The machine

The notebook is an MSI Pulse 16 AI C1VGKG. According to the official MSI specification for the Pulse 16 AI C1V line, the configuration includes a 16 inch QHD+ 2560x1600 display at 240 Hz, an NVIDIA GeForce RTX 4070 Laptop GPU with 8 GB GDDR6 in the C1VGKG model, DDR5 5600 memory with two slots and support for up to 96 GB, two M.2 NVMe PCIe Gen4 slots, Wi-Fi 6E with Bluetooth 5.3, a 90 Wh battery, a 240 W power adapter and a weight of 2.5 kg.

Source: MSI Pulse 16 AI C1V Specification, https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification

Item Specification
Model MSI Pulse 16 AI C1VGKG
Display 16 inch QHD+ 2560x1600, 240 Hz, IPS-Level
CPU Intel Core Ultra 9, with Intel AI Boost NPU
GPU NVIDIA GeForce RTX 4070 Laptop GPU, 8 GB GDDR6
Memory DDR5 5600, two slots, up to 96 GB
Storage 2 M.2 NVMe PCIe Gen4 slots
Network Wi-Fi 6E and Bluetooth 5.3
Battery and power 90 Wh battery and 240 W adapter
Weight 2.5 kg

First step: hostname and a clean base

After the installation, the first thing I did was give the machine a proper name. That avoids the usual mess of generic hostnames in the network, terminal, Tailscale and SSH.

sudo hostnamectl set-hostname amon
hostnamectl
sudo dnf upgrade --refresh -y

RPM Fusion and NVIDIA without the manual installer

The sensitive part was NVIDIA. Instead of downloading the .run installer from NVIDIA and creating a maintenance problem for myself, I used RPM Fusion and akmod-nvidia. That is the cleaner path on Fedora because the module follows kernel updates instead of becoming a manual hack.

Before installing the driver, I checked that Secure Boot was disabled. That avoids the whole module signing soap opera. Then I installed the driver, let akmods build the module, regenerated initramfs and validated everything with nvidia-smi.

mokutil --sb-state
sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda nvidia-settings kernel-devel-matched
sudo akmods --force
sudo dracut --force
sudo reboot
nvidia-smi
lsmod | grep nvidia
__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia glxinfo | grep "OpenGL renderer"

Multimedia, codecs and video acceleration

Fedora is more careful with codecs by default. That is nice in theory, but in real use I want video working, media players behaving and hardware acceleration doing its job. So I finished the multimedia setup through RPM Fusion and installed VA-API pieces for Intel and NVIDIA.

sudo dnf swap -y ffmpeg-free ffmpeg --allowerasing
sudo dnf group install -y multimedia
sudo dnf install -y libva libva-utils intel-media-driver libva-nvidia-driver
vainfo

DNF and diagnostic tools

I also tuned DNF a little and installed diagnostic tools. Not because I want to stare at pretty graphs all day, but because it is useful when something heats up, freezes or starts eating resources without asking permission.

sudo tee -a /etc/dnf/dnf.conf >/dev/null <<'EOF'
# Amon tuning
max_parallel_downloads=10
fastestmirror=True
defaultyes=True
keepcache=False
EOF

sudo dnf clean all
sudo dnf makecache
sudo dnf install -y btop htop fastfetch inxi lm_sensors pciutils usbutils lshw nvtop powertop tuned tuned-ppd tuned-utils mesa-demos vulkan-tools mangohud goverlay akmods kernel-devel-matched

Power profile

For performance, I enabled tuned and used throughput-performance. It is great on AC power, but it is not magic. More performance usually means more heat and more power draw. For normal use, balanced is still the smarter default.

sudo systemctl enable --now tuned
sudo tuned-adm profile throughput-performance
tuned-adm active
sudo tuned-adm profile balanced

Ghostty as the default terminal

Ghostty became the default terminal. I wanted something modern, fast and good looking. The only small trap was the theme name. On Linux, theme names are literal. catppuccin-latte did not work. Catppuccin Latte did.

mkdir -p ~/.config/ghostty

cat > ~/.config/ghostty/config <<'EOF'
font-family = JetBrains Mono
font-size = 12
theme = light:Catppuccin Latte,dark:Catppuccin Mocha
window-padding-x = 8
window-padding-y = 8
copy-on-select = clipboard
confirm-close-surface = false
EOF

ghostty +list-themes | grep -i cat

Writing, PDF and EPUB

The machine was also built as a writing workstation. I installed ghostwriter for Markdown, LibreOffice for traditional documents, Okular for reading and annotating PDFs, PDF Arranger for simple PDF surgery, Calibre for ebook management and conversion, Sigil for proper EPUB editing and Zotero for references.

In the terminal, I installed the tools that actually solve problems: Pandoc for document conversion, qpdf and poppler-utils for PDF work, OCRmyPDF and Tesseract for OCR, ImageMagick for images, ExifTool for metadata and TeX Live for PDF generation through LaTeX.

flatpak install -y flathub org.kde.ghostwriter org.libreoffice.LibreOffice org.kde.okular com.github.jeromerobert.pdfarranger com.calibre_ebook.calibre com.sigil_ebook.Sigil org.zotero.Zotero

sudo dnf install -y pandoc qpdf poppler-utils ocrmypdf tesseract tesseract-langpack-por tesseract-langpack-eng ImageMagick djvulibre perl-Image-ExifTool unpaper texlive-scheme-medium texlive-collection-fontsrecommended texlive-collection-latexrecommended texlive-collection-latexextra

IRC with Halloy

For IRC, I installed Halloy through Flatpak and replicated my Windows configuration into config.toml. The important part was not publishing passwords or private server details. Anything with credentials stays local.

mkdir -p ~/.var/app/org.squidowl.halloy/config
nano ~/.var/app/org.squidowl.halloy/config/config.toml
chmod 600 ~/.var/app/org.squidowl.halloy/config/config.toml

Tailscale and SSH

Remote access is handled through Tailscale and SSH. That lets me work on the machine from Windows without exposing SSH to the whole internet. sshd was enabled, and the machine became reachable through the Tailnet address.

sudo dnf install -y openssh-server
sudo systemctl enable --now sshd
systemctl status sshd --no-pager
tailscale ip -4
tailscale status

Cloud storage on Fedora KDE

I did not want to depend on KDE native Google Drive integration. It exists, but it is not something I would build my whole workflow around. To mount cloud services as a local filesystem, I installed ExpanDrive through its RPM. The installation completed properly and left the binary available on the system.

mkdir -p ~/Downloads
cd ~/Downloads
curl -L -A "Mozilla/5.0" -o ExpanDrive.rpm "https://www.expandrive.com/api/download/expandrive?platform=linux&ext=rpm"
file ExpanDrive.rpm
sudo dnf install -y ./ExpanDrive.rpm
rpm -qa | grep -i expandrive
command -v expandrive

Dark KDE, fonts and icons

The visual side was handled carefully because I was doing it remotely over SSH. I did not try to rebuild panels or install random KDE Store themes without seeing the screen. I applied a safe base: Breeze Dark, Papirus Dark, Noto Sans for the interface and JetBrains Mono for fixed-width text.

sudo dnf install -y papirus-icon-theme google-noto-sans-fonts google-noto-serif-fonts jetbrains-mono-fonts kvantum qt6ct qt5ct breeze-gtk

kwriteconfig6 --file kdeglobals --group General --key ColorScheme BreezeDark
kwriteconfig6 --file kdeglobals --group KDE --key LookAndFeelPackage org.kde.breezedark.desktop
kwriteconfig6 --file kdeglobals --group KDE --key widgetStyle Breeze
kwriteconfig6 --file kdeglobals --group Icons --key Theme Papirus-Dark
kwriteconfig6 --file kdeglobals --group General --key fixed "JetBrains Mono,10,-1,5,50,0,0,0,0,0"
kwriteconfig6 --file kdeglobals --group General --key font "Noto Sans,10,-1,5,50,0,0,0,0,0"

Power: plugged in, no sleeping from idle

The power behavior ended up exactly how I wanted it. When plugged in, the notebook should not turn off the display or suspend by itself because of inactivity. But when I close the lid, it can suspend. That is the correct middle ground for a notebook.

kwriteconfig6 --file powerdevilrc --group AC --group DPMSControl --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group DimDisplay --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group SuspendSession --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group HandleButtonEvents --key lidAction 1
systemctl --user restart plasma-powerdevil.service 2>/dev/null || systemctl --user restart plasma-powerdevil

sudo tee /etc/systemd/logind.conf.d/99-amon-power.conf >/dev/null <<'EOF'
[Login]
HandleLidSwitchExternalPower=suspend
HandleLidSwitchDocked=suspend
IdleAction=ignore
EOF

sudo systemctl restart systemd-logind

OpenPGP, development and AI in the terminal

For OpenPGP, the natural choice on Fedora KDE was Kleopatra, together with GnuPG and pinentry-qt. For development, I installed a Python and Go base. For AI in the terminal, I installed Claude Code through the official repository, with the rule of running it inside project directories, not across my whole home folder.

sudo dnf install -y kleopatra gnupg2 pinentry-qt kgpg

sudo dnf install -y python3 python3-pip python3-devel python3-virtualenv pipx golang git gcc gcc-c++ make cmake pkgconf-pkg-config openssl-devel libffi-devel zlib-devel bzip2-devel xz-devel sqlite-devel readline-devel

claude doctor
claude --version

Image work and visual creation

For images, GIMP, Krita and Inkscape already cover a lot of ground. GIMP is for general editing, Krita is for art and covers, and Inkscape is for vector work. darktable and RawTherapee are next candidates if I want a more serious photography and RAW workflow.

flatpak list --app | grep -Ei "gimp|krita|inkscape"

Amon now has Fedora KDE, working NVIDIA support, Wayland, RTX 4070 offload, codecs, a complete writing setup, remote access through SSH and Tailscale, cloud mounting prepared with ExpanDrive, Ghostty as the terminal, IRC in Halloy and a clean KDE visual base.

There is still room to tune panels, wallpaper, widgets and small visual details when I am physically in front of the machine. Over SSH, I stopped at the right point. Going further with visuals without looking at the screen would be vanity with a decent chance of breaking something that already works.

The best part is that the installation did not turn into a monster. It has strong tools, but not random packages installed just because they looked nice in a Reddit screenshot.

Source used only for the notebook specification: MSI, Pulse 16 AI C1V Specification, https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification

Read more