Rebuilding a Compromised Corporate Website: WordPress to Static Astro
| Client | Industrial B2B company (heat transfer / engineering solutions) |
| Role | Solo developer |
| Stack | Astro TypeScript i18n routing |
The problem
The client's WordPress site got compromised. During incident response we found malicious files planted on the server, a spam-sending script, and RCE backdoors. Basically what happens when a page-builder plugin and shared hosting sit unpatched for too long. As part of the cleanup, the hosting provider's firewall started blocking the page builder's cloud compiler too, so the client couldn't even edit their own site anymore after the malware was removed.
I was brought in to fix the immediate problem and make a call: patch the old stack, or rebuild.
The decision
I went with a full rebuild as a static site instead of patching WordPress. Reasoning:
- Attack surface. A static site has no server-side app code, no database, no admin login to brute-force. Most of what made the original site vulnerable (mutable database, PHP runtime, plugin code) just isn't there anymore.
- No third-party dependency risk. The page builder that broke the client's editing workflow was a proprietary cloud service outside my control. Removing that dependency removed a single point of failure.
- Content as code. Moving content into version-controlled files instead of a database gives you an audit trail for free. Every content change is a commit, not an invisible database write.
The trade-off, and I was upfront with the client about this, is they lose WordPress's self-service editing. Updating content now goes through a developer instead of a non-technical staff member logging into a dashboard. For a site that's mostly stable marketing/case-study content and not a blog with daily posts, that trade-off made sense here, but it's a real cost, not a free upgrade, and I told them that.
What I built
- Full site rebuild in Astro: home, about, contact, solutions (4 categories), services (6 offerings)
- A project showcase with 130+ individual case-study pages, paginated listings, and prev/next navigation
- Full bilingual support (English + Indonesian) via Astro's native i18n routing, with every UI string and nav label translated
- A data migration pipeline: a script that pulled all project records and their images directly out of the old WordPress database, rather than hand-copying 130+ entries
- Image optimization pass converting key assets to Astro's
<Image>component for automatic WebP output
Verifying the content
The client's brief for the rebuild wasn't a reliable source of truth on its own, turned out several details in it (certification numbers, a client name in a case study) didn't match reality. So I cross-referenced three sources to catch this: the live production site, a local mirror of the original WordPress install, and the raw database export. That process caught a bunch of factual errors before they made it into the new site. Good reminder that what the client tells you and what's actually true aren't always the same thing, worth double-checking.
What I'd do differently
- Plan the i18n scope earlier. Translating 130+ case-study detail pages was out of scope for the initial pass, so they currently stay in English even on the Indonesian version of the site. In hindsight I'd have flagged this trade-off with the client before building rather than after.
- Image handling for bulk content. The sitewide assets (logo, badges, hero images) are fully optimized, but most of the project photography still loads as plain images instead of through Astro's image pipeline. Restructuring that would've meant importing 100+ images as modules, a decision that's easy in isolation but gets expensive at scale, and one I'd budget time for upfront next time.
Outcome
The rebuilt site runs with zero server-side attack surface, in two languages, with content the client's team can review and I can update through normal version control instead of a vulnerable admin panel.
Ditching the PDF library in a legacy Android app
| Status | Running in production now, not a proposal |
| Role | Solo developer |
| Stack | Android Kotlin Bosnet framework AGP 3.6.4 Java 8 Gradle 6.5 |
Why this happened
Google Play started enforcing 16KB page size alignment for native .so files. The app was using com.github.barteksc:android-pdf-viewer:3.1.0-beta.1, which bundles PDFium (native PDF engine) compiled for 4 architectures. Those native libs were only 4KB-aligned, so uploads got blocked.
The catch: the project's still on AGP 3.6.4 / Java 8, a 2020-era toolchain. Can't just upgrade the whole project to fix one library, too much breaking-change risk elsewhere.
What I tried before giving up on the library
| Option | Outcome |
|---|---|
| Newer library version with 16KB support | Found io.github.oothp:android-pdf-viewer:3.2.0-beta06 on Maven Central, artifact was broken, 76KB instead of the expected 10-20MB |
| Use it anyway | Nope, compiled with Java 17, incompatible with AGP 3.6.4 |
| Fork it, downgrade the build config to match the project | Tried this. Cascading Gradle/AGP/dependency mess, Guava variant conflicts, Gradle version mismatches, missing plugin declarations. Rabbit hole. |
| Upgrade whole project to AGP 8 / Java 17 | Nope, 1-2 weeks, high risk, way overkill just to fix a PDF viewer |
Use Android's built-in PdfRenderer instead | This. No native deps, works fine on Java 8. |
About 3 hours in, still no working build, decided to stop chasing the library and just build it myself instead. Ended up being the right call, took about 4 hours total and now it's just... solved, permanently.
What I built
- PdfRendererHelper. Wraps
android.graphics.pdf.PdfRenderer(built into Android since API 21). Opens the PDF viaParcelFileDescriptor, renders a page to aBitmapkeeping aspect ratio, exposespageCount, closes cleanly to avoid leaks. - PdfPageAdapter. RecyclerView adapter, one page per list item. Renders each page on
Dispatchers.IOso it never blocks the UI thread. UsesSupervisorJobso one page failing doesn't kill the rest. Cancels the coroutine scope on cleanup. - ZoomableImageView. Custom
ImageView, no external zoom library.Matrixfor scale/pan (transform math, not re-rendering bitmaps, cheap).ScaleGestureDetectorfor pinch,GestureDetectorfor double-tap toggle, manualACTION_MOVEfor single-finger pan.fixTranslation()keeps the image from being dragged off-screen. Had to addparent?.requestDisallowInterceptTouchEvent()because panning a zoomed page was getting swallowed by the RecyclerView as a scroll, classic nested-gesture conflict. - PdfScrollHandle. Small TextView overlay, "page X / Y", updates on scroll, auto-hides. Basically recreating the old library's scroll indicator.
Wiring it up
- Dropped
com.github.barteksc:android-pdf-viewerfromapp/build.gradle - Swapped
PDFViewforRecyclerViewin bothfragment_pdf_viewer.xmlandfragment_product_learning.xml LearningPdfFragment.kt/ProductLearningFragment.kt: removed the old.fromFile().load()chain, wired inPdfRendererHelper+PdfPageAdapter, added cleanup inonDestroyView()
Numbers
| Metric | Before | After | Change |
|---|---|---|---|
| Debug APK | ~44.8 MB | ~25 MB | -20 MB |
| Release APK (raw) | ~40 MB | 21.2 MB | -18.8 MB |
| Release APK (download size) | ~38 MB | 19.4 MB | -18.6 MB (~49%) |
| Native .so libraries | ~20 MB across 4 architectures | 0 MB | Fully eliminated |
| 16KB page size compliance | Blocked | Pass | Fixed |
| External PDF dependency | 1 (unmaintained fork risk) | 0 | Removed |
The lib/ folder disappeared from the APK analyzer entirely once the library was gone, that library was the only native code in the whole app.
Why one library was 20MB
Native code gets compiled separately per CPU architecture. One PDF engine (~4-6MB of compiled C++) times 4 architectures (armeabi-v7a, arm64-v8a, x86, x86_64) equals ~20MB in the APK, even though any single phone only ever uses one of those four. Easy to miss until you actually look at the APK analyzer. Same thing applies to video codecs, ML models, maps SDKs, anything wrapping native rendering.
Rule of thumb: if a library ships .so files, assume ~4x the size a "normal" library would be.
What I'd do differently / keep in mind next time
- Java version + AGP version compatibility isn't something you can Gradle-file your way around. If a library's compiled with Java 17, it's not running on AGP 3.6.4, full stop.
- ~3 hours with no working build is the signal to stop and reconsider, not push harder.
- Check if Android already does this natively before reaching for a library.
PdfRendererexisted since API 21, the whole dependency was solving an already-solved problem. - Building it myself wasn't slower. ~4 hours vs 3+ hours already spent fighting versions with nothing to show for it.
requestDisallowInterceptTouchEventis the fix whenever a custom gesture view sits inside a scrollable parent (RecyclerView, ViewPager, etc.) and gestures get stolen. Not obvious from the View API, good to remember.- Every dependency is a future liability, not just a today decision. Before adding one: is it maintained, does it ship native code, does the platform already do this, could I build it myself in under a week.
Multi-Level Approval Engine
| Project | An enterprise lubrication management platform |
| Role | Fullstack developer (backend: C#/.NET Framework on Bosnet, frontend: Angular) |
| Stack | C# .NET Framework 4.8 Bosnet SQL Server |
Context
The platform is an enterprise lubrication management system built on Bosnet, a proprietary in-house framework (C# .NET Framework 4.8, SQL Server, on-premise Windows Service). Several transactional workflows in the system, Assessments, Oil Consumption records, and Lube Stock movements, required review and sign-off before they could be finalized. Each workflow had different stakeholders, different numbers of review stages, and different rules for who needed to be notified at each step. There wasn't an existing generalized way to express "this transaction needs N levels of review, with conditional skips," so that logic would otherwise have been duplicated per module.
Problem
Assessments, Oil Consumption, and Lube Stock transactions each needed configurable approval chains, not a fixed 2-step "submit → approve" flow. Requirements included a variable number of approval levels depending on transaction type and context (value thresholds, site, or role availability), skip logic for when a level's approver role wasn't applicable or required, notification cascades where each transition (submitted, approved at level N, rejected, fully approved) needed to notify a different context-dependent set of people, and mobile button visibility, since the companion Android app needed to show/hide approve/reject actions per user per transaction based on whose turn it was, without duplicating the approval-state logic on the client.
Design
The engine treats an approval chain as an ordered sequence of levels tied to a transaction type. State (current level, status, history) is persisted per transaction, and each transition is validated server-side before it's allowed to proceed. The mobile app only ever reflects state, it doesn't own it. That was a deliberate choice: with three transaction types sharing the same underlying state machine, keeping the authority for "whose turn is it" and "can this user act" entirely server-side avoided drift between web, mobile, and any future clients.
Skip logic was implemented as a per-level applicability check evaluated at chain-build time rather than hardcoded per transaction type, so adding a new condition for skipping a level didn't require touching the transition logic itself.
Each transition also raises an event (submitted, approved at level N, rejected, fully approved) that a separate notification mapping system listens to for routing who gets notified. The approval engine doesn't know or care who's on the recipient list, that's intentionally someone else's problem.
Constraints from the Bosnet framework
Bosnet's CreateObject service locator pattern makes conventional unit testing and mocking impractical, there's no clean seam to substitute dependencies. Combined with BosSafeTx for transaction management, this meant the approval state transitions had to be reasoned through carefully by hand (and reviewed manually against edge cases) rather than covered by an automated test suite. That's a known limitation of the framework, not something scoped to be solved within this feature.
Notable bug caught during development
Concurrent approval actions on the same transaction (two approvers acting near-simultaneously) surfaced a race condition, a classic case for UPDLOCK/ROWLOCK at the SQL level. This is the kind of logic error that doesn't show up as a compile or runtime exception, it only shows up under concurrent load, which made it a valuable catch relative to the effort of finding it.
Reconstructing an undocumented invariant
The original gating function, CanUserApproveAtLevel, decided whether a given user could act at their assigned level. It worked correctly for exactly three levels, but the logic was hand-enumerated per level:
private bool CanUserApproveAtLevel(Guid gdApproved, string szPosition, BosSafeTx safeTx)
{
var approvedData = p_JustGetApproved(gdApproved, safeTx);
if (approvedData.bRejected) return false;
var allApprovedItemList = approvedData.approvedItemData;
var userItem = allApprovedItemList.FirstOrDefault(x => x.szPosition.Equals(szPosition, StringComparison.OrdinalIgnoreCase));
if (userItem == null) return false;
int btApprovedLevel = userItem.btApprovedLevel;
if (btApprovedLevel == 1) return true;
var approvedItemLevel1 = allApprovedItemList.FirstOrDefault(x => x.btApprovedLevel == 1);
if (btApprovedLevel == 2)
return approvedItemLevel1 == null || approvedItemLevel1.bApproved || approvedItemLevel1.bAllowToSkipNext;
if (btApprovedLevel == 3)
{
var approvedItemLevel2 = allApprovedItemList.FirstOrDefault(x => x.btApprovedLevel == 2);
bool bL1Skip = approvedItemLevel1?.bAllowToSkipNext ?? false;
bool bL1Approved = approvedItemLevel1?.bApproved ?? false;
bool bL2Skip = approvedItemLevel2?.bAllowToSkipNext ?? false;
bool bL2Approved = approvedItemLevel2?.bApproved ?? false;
return (bL1Skip && bL2Skip) || (bL1Approved && bL2Skip) || (bL1Approved && bL2Approved);
}
return false;
}
Adding a fourth approval level would've meant hand-deriving a new set of AND/OR combinations by inspection, exactly the kind of manual boolean enumeration that's easy to get subtly wrong.
Recovering the actual rule. bAllowToSkipNext doesn't mean "this level is bypassed," it means "I'm letting the next level go before me, but I still have to approve eventually." Working from that, I reconstructed the full truth table for level 3 directly against the existing behavior:
| L1.Skip | L1.Approved | L2.Skip | L2.Approved | Can L3 approve? |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | No |
| 0 | 0 | 0 | 1 | No |
| 0 | 0 | 1 | 0 | No |
| 0 | 0 | 1 | 1 | No |
| 0 | 1 | 0 | 0 | No |
| 0 | 1 | 0 | 1 | Yes |
| 0 | 1 | 1 | 0 | Yes |
| 0 | 1 | 1 | 1 | Yes |
| 1 | 0 | 0 | 0 | No |
| 1 | 0 | 0 | 1 | No |
| 1 | 0 | 1 | 0 | Yes |
| 1 | 0 | 1 | 1 | Yes |
| 1 | 1 | 0 | 0 | No |
| 1 | 1 | 0 | 1 | Yes |
| 1 | 1 | 1 | 0 | Yes |
| 1 | 1 | 1 | 1 | Yes |
Two combinations decide everything: either the entire chain below defers together (a clean pass-through, nobody's approved yet but everyone agreed to hand it off), or level 1 has genuinely approved, in which case every level between it and the current one just needs to be resolved (approved or deferred, in any order). Level 1 acts as a fixed anchor: its own defer only counts if every level below it also defers as a block. The instant one level in the middle actually approves instead of deferring, level 1's approval stops being optional.
A useful mental model: think of it as a relay baton. If every runner before you agrees to let the next one carry it, the baton passes cleanly all the way down, untouched. But the moment someone in the middle actually runs their leg instead of deferring, the clean pass-through breaks, and now the start line (level 1) has to be real, not just agreed-upon.
This generalizes to any number of levels without adding new hand-written branches:
private bool CanUserApproveAtLevel(Guid gdApproved, string szPosition, BosSafeTx safeTx)
{
var approvedData = p_JustGetApproved(gdApproved, safeTx);
if (approvedData.bRejected) return false;
var allApprovedItemList = approvedData.approvedItemData;
var userItem = allApprovedItemList.FirstOrDefault(x =>
x.szPosition.Equals(szPosition, StringComparison.OrdinalIgnoreCase));
if (userItem == null) return false;
int currentLevel = userItem.btApprovedLevel;
if (currentLevel == 1) return true;
return CanReachLevel(allApprovedItemList, currentLevel);
}
/// <summary>
/// A level N can be approved if either:
/// (A) every level below N deferred (bAllowToSkipNext), passing the zone straight down, OR
/// (B) level 1 genuinely approved, and every level between 1 and N is resolved
/// (approved, deferred, or simply not assigned).
/// Level 1 is a fixed anchor by design, if a future requirement allows any level
/// to serve as the anchor, this formula needs to change.
/// </summary>
private bool CanReachLevel(List<ApprovedItemData> items, int level)
{
if (level <= 1) return true;
var priorLevels = Enumerable.Range(1, level - 1)
.Select(lvl => items.FirstOrDefault(x => x.btApprovedLevel == lvl))
.ToList();
bool bFullDefer = priorLevels.All(item => item != null && item.bAllowToSkipNext);
if (bFullDefer) return true;
var anchor = priorLevels[0];
if (anchor == null || !anchor.bApproved) return false;
for (int i = 1; i < priorLevels.Count; i++)
{
var item = priorLevels[i];
bool resolved = item == null || item.bApproved || item.bAllowToSkipNext;
if (!resolved) return false;
}
return true;
}
Splitting CanReachLevel out also had a practical side benefit in a codebase where CreateObject's service locator pattern makes conventional mocking impractical: it takes plain data in and returns a bool, no framework dependency involved, so it's one of the few pieces of this logic that can actually be unit tested in isolation.
A separate, easily-conflated concern: acting vs. completing. CanUserApproveAtLevel only answers "can this level act right now," it governs order, not requirement. Deferring is never a substitute for approving, it only changes when a level is allowed to try. Whether the transaction is fully approved is a completely independent, much simpler check: every level's bApproved must eventually be true, regardless of the order that happened in:
bool bFullyApproved = allApprovedItemList.All(x => x.bApproved);
Keeping "who can act now" and "is this done" as two separate questions, rather than folding completion logic into the gating function, kept each piece small enough to reason about on its own, which mattered given there was no test suite to lean on instead.
Technical highlights
| Area | Detail |
|---|---|
| Backend | C# .NET Framework 4.8, Bosnet (CreateObject, BosSafeTx) |
| Data integrity | No database foreign keys in this codebase by team convention, referential integrity enforced in application logic, so approval-state consistency had to be validated defensively at the service layer |
| Mobile | Approval action visibility (approve/reject buttons) driven by server-resolved state, not client-side inference |
| Concurrency | Row-level locking (UPDLOCK/ROWLOCK) applied after identifying a race condition in concurrent approval actions |
Outcome
The approval engine now backs all three transaction types (Assessments, Oil Consumption, Lube Stock) through a single shared state machine rather than three separate implementations. New approval policies can be added through configuration rather than code changes in the common case.
Skills demonstrated
- Designing a shared state machine for multiple transaction types instead of duplicating flow logic per feature
- Keeping approval authority server-side so web, mobile, and future clients can't drift out of sync
- Diagnosing and fixing a concurrency bug that wouldn't surface without concurrent load
- Reverse-engineering an undocumented invariant out of hand-enumerated code and generalizing it into a rule that scales to any number of levels
- Isolating the one testable seam (
CanReachLevel) out of an otherwise untestable, framework-coupled codebase - Working within legacy framework constraints (no DI/mocking, no FK-based integrity) without compromising correctness
Notification Mapping System
| Project | An enterprise lubrication management platform |
| Role | Fullstack developer (backend: C#/.NET Framework on Bosnet, frontend: Angular) |
| Stack | C# .NET Framework 4.8 Bosnet SQL Server Angular 7 |
Context
Alongside the multi-level approval engine, the platform also needed a way to decide who gets notified, for what event, and through what channel, across Assessments, Oil Consumption, Lube Stock, and Issues. Notification recipients weren't a fixed list, they varied by event type, transaction type, site, and role, and needed to be maintainable by non-developers without a deployment.
Problem
Hardcoding recipient logic per event, as the approval engine's transitions would have required if left unaddressed, would have coupled notification rules tightly to approval code and made every policy change a code change. Notification rules also needed to change independently of approval logic, and by people who aren't developers.
Design
Built as a standalone CRUD module: a data model mapping event type to recipient rule (role, site, or specific user), independent of any single feature; an Angular DataTables front end for browsing and managing mappings, with cascading dropdowns (selecting a site scopes the available roles/users) to keep the configuration UI usable at scale rather than a flat unconstrained form; and a reminder scheduler (SchedulerReminderNotifications) built on Bosnet's IWorkingThread/SchedulerLib infrastructure, using UNION SQL queries across Assessments, Oil Consumption, Issues, and Approvals to produce a single reminder feed, resolved against NotifMapping for recipient routing.
The key architectural decision was decoupling notification routing from whatever produces the event. The approval engine raises events, this system independently resolves who should know about it. That meant the approval chain's transition logic didn't need to know anything about recipients, and notification policy could change without touching approval code, and vice versa.
Technical highlights
| Area | Detail |
|---|---|
| Backend | C# .NET Framework 4.8, Bosnet (IWorkingThread/SchedulerLib), UNION queries across four modules |
| Frontend | Angular 7, Angular DataTables, ng-multiselect-dropdown, RxJS |
| Data integrity | No database foreign keys in this codebase by team convention, referential integrity enforced in application logic, so mapping consistency had to be validated defensively at the service layer |
Outcome
The notification mapping system is reused as the routing layer for reminders across Assessments, Oil Consumption, Lube Stock, and Issues. New notification rules can be added through configuration rather than code changes in the common case.
Skills demonstrated
- Decoupling event production (approval transitions) from event consumption (notification routing) as separate concerns
- Building configuration-driven admin tooling (Angular DataTables + cascading dropdowns) so non-developers can maintain business rules
- Designing a reusable routing layer instead of hardcoding recipients per feature
Building a Production Mail Server from Scratch
| Role | Infrastructure / DevOps (personal project) |
| Stack | Postfix Dovecot OpenDKIM SpamAssassin Fail2ban Let's Encrypt Apache DNS Bash |
Problem
A lot of small businesses end up either paying for hosted email or running a mail server that's misconfigured just enough to get flagged as spam. I wanted to see if I could build the real thing myself: a self-hosted mail server on a Linux VPS that could host multiple domains, run virtual mailboxes, support encrypted SMTP/IMAP, pass modern email authentication, and actually land in Gmail's inbox instead of spam.
Architecture
Internet
│
▼
Postfix (SMTP)
│
▼
Dovecot LMTP delivery
│
▼
Virtual mailboxes
│
▼
Mail clients (IMAP)
Plus OpenDKIM, SpamAssassin, Fail2ban, Let's Encrypt, Apache, and Webmin sitting around that core.
Major challenges
Virtual mailboxes. Instead of creating a Linux user for every mailbox, I set up Postfix's virtual mailbox architecture with Dovecot LMTP delivery: virtual mailbox maps, alias maps, dedicated mail storage, and UID/GID mapping. That keeps mail users separate from OS users entirely, which makes managing multiple domains a lot easier and cuts down the attack surface.
DNS authentication. Configured SPF, DKIM, and DMARC, then kept iterating against Mail Tester until every check passed.
Gmail deliverability. This was the hardest one. Gmail was rejecting outgoing mail even though SPF and DKIM were both valid. Turned out the VPS didn't have a PTR record, no reverse DNS. Once I got that configured with the hosting provider, delivery to Gmail cleared up. Good reminder that SMTP reputation depends on forward-confirmed reverse DNS and DNS consistency, not just SPF/DKIM being technically correct.
TLS certificates. Let's Encrypt for SMTP/IMAP, with renewal issues along the way from port conflicts, DNS validation failures, and Apache config getting in the way.
Spam protection. SpamAssassin for filtering, Fail2ban for brute-forcing attempts against SMTP auth.
Troubleshooting along the way
- SMTP authentication failures
- IMAP connectivity issues
- DNS propagation delays
- Mail routing problems
- DKIM signing failures
- SPF syntax errors
- File permission issues
- TLS certificate validation failures
- Gmail rejecting outgoing mail
- Reverse DNS configuration
Results
Ended up with a production-capable mail server: multi-domain support, virtual mailboxes, secure SMTP/IMAP, DKIM signing, SPF and DMARC validation, TLS encryption, spam filtering, and reliable Gmail delivery. Hit a 10/10 on Mail Tester, and mail lands in the Gmail inbox instead of spam after fixing the reverse DNS.
Lessons learned
Building this made it obvious that reliable email delivery isn't one application working correctly, it's SMTP services, DNS records, TLS certificates, authentication protocols, and server reputation all lining up at once. Most of the real debugging was evidence-driven: correlating logs, checking DNS propagation, and figuring out how providers like Gmail actually evaluate incoming mail rather than guessing.
Skills demonstrated
- Linux administration: service management, file permissions, system configuration
- Networking: SMTP, IMAP, DNS, TLS, reverse DNS
- Security: DKIM, SPF, DMARC, TLS, spam protection, brute-force mitigation
- Troubleshooting: log analysis, DNS debugging, email deliverability, authentication debugging, certificate management
- Infrastructure: service integration, production deployment, monitoring and validation