惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
Microsoft Azure Blog
Microsoft Azure Blog
I
InfoQ
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
人人都是产品经理
人人都是产品经理
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tailwind CSS Blog
MongoDB | Blog
MongoDB | Blog
Google DeepMind News
Google DeepMind News
WordPress大学
WordPress大学
量子位
美团技术团队
大猫的无限游戏
大猫的无限游戏
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Last Week in AI
Last Week in AI
博客园 - 司徒正美
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
小众软件
小众软件
C
Check Point Blog
博客园 - 三生石上(FineUI控件)
N
Netflix TechBlog - Medium
Recent Announcements
Recent Announcements
有赞技术团队
有赞技术团队
月光博客
月光博客

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Post-Mortem: Why My Blog Cover Images Silently Failed to ...
Vicente G. R · 2026-05-22 · via DEV Community

Six blog posts on my portfolio had broken cover images for longer than I'd like to admit. The images were in S3. The management command to restore them had been written and run. And yet — nothing. Blank covers, every time.

Here's the full breakdown of what went wrong, why, and how it got fixed.

The Setup

My portfolio backend is a Cookiecutter Django project running on a DigitalOcean droplet with Docker Compose. Blog post cover images are stored on AWS S3. The cover field on the BlogPost model is an ImageField that stores a relative path like blog_posts/deploy.webp — Django's S3 storage backend handles prepending the media/ prefix and building the full URL.

When I migrated away from using Hashnode as a headless CMS and imported all posts into my own Django backend, the cover images came along as CUID-based filenames (e.g. blog_posts/cmoxrumae00ms2em7bje5at07.png). Those CUIDs don't exist in S3 — the actual files were uploaded separately with descriptive names like deploy.webp, manual.webp, sortinghashnode.webp.

To fix this, Gemini wrote restore_covers.py, a management command with two matching strategies:

  1. Exact CUID match — looks for blog_posts/{cuid}.webp etc. in S3
  2. Fuzzy slug match — tokenizes the post slug and looks for S3 filenames with overlapping keywords

The command was run. Six posts still had broken covers.

Root Cause 1: The Fuzzy Matcher Couldn't Tokenize Concatenated Filenames

The S3 filenames are lowercase concatenated words: sortinghashnode.webp, trackingpage.webp, postmortem.webp. The fuzzy matcher works by calling get_keywords() on both the post slug and each S3 filename, then computing the set intersection.

Here's the problem. get_keywords() uses re.findall(r"[a-zA-Z0-9]+", text) to tokenize. Applied to a filename:

get_keywords("sortinghashnode.webp")
# → {"sortinghashnode"}   ← one token

Enter fullscreen mode Exit fullscreen mode

And for the post slug:

get_keywords("sorting-hashnode-series-posts-how-to-display-the-latest-post-first")
# → {"sorting", "hashnode", "series", "posts", "display", "latest", "first"}

Enter fullscreen mode Exit fullscreen mode

The intersection of {"sortinghashnode"} and {"sorting", "hashnode", ...} is empty. Score = 0. No match.

The same failure applied to every concatenated filename in the bucket. trackingpage.webp couldn't match a slug containing tracking and page. postmortem.webp couldn't match a slug containing mortem (since post is a stop word). None of them scored above 0.

The fix was to replace the plain set intersection with substring containment, enforcing a minimum token length of 4 characters to prevent false positives from short words:

min_token_len = 4
overlap = set()
for pk in post_keywords:
    for fk in file_keywords:
        if pk == fk or (
            len(pk) >= min_token_len
            and len(fk) >= min_token_len
            and (pk in fk or fk in pk)
        ):
            overlap.add(pk)

Enter fullscreen mode Exit fullscreen mode

Now "sorting" in "sortinghashnode" → True, score += 1. "hashnode" in "sortinghashnode" → True, score += 1. The correct file gets matched.

Root Cause 2: No AWS Credentials in the Local Environment

The second reason the command silently failed: it was run via docker compose -f docker-compose.local.yml, which loads .envs/.local/.django. That file has no AWS credentials.

When storage.listdir("blog_posts") is called with no S3 credentials, it either errors out silently (caught by a bare except Exception) or returns an empty list because the local filesystem storage backend is active instead of S3. The command's output showed:

Could not list storage directory directly: ...
Fuzzy matching won't be available.

Enter fullscreen mode Exit fullscreen mode

But the overall command still exited 0 with a summary that made it look like it ran fine. With zero files in storage_files, the fuzzy loop had nothing to iterate over — so every post hit the "no existing file found in storage" branch.

The management command is designed to run in the production container, where .envs/.production/.django already has the correct AWS credentials wired up.

Root Cause 3: One Image Was Never Uploaded to S3

Even with both fixes above, the post "How I Fixed the Hashnode GraphQL API Stale Cache Bug (Stellate CDN)" would still have a broken cover — because no matching file exists in S3 at all. The DB had blog_posts/cmlyqj0cc006627lvguola3gg.png and no file with a descriptive name was ever uploaded for it.

This one requires a manual upload via Django admin.

The Fix

For the five posts with known S3 matches, I wrote a Django data migration that directly sets the correct cover paths:

COVER_FIXES = {
    "how-to-manually-backup-wordpress-sites-via-ssh": "blog_posts/manual.webp",
    "deploying-cookiecutter-django-on-a-digitalocean-droplet-ubuntu-24-04-lts": "blog_posts/deploy.webp",
    "post-mortem-the-march-2026-axios-supply-chain-attack": "blog_posts/postmortem.webp",
    "sorting-hashnode-series-posts-how-to-display-the-latest-post-first": "blog_posts/sortinghashnode.webp",
    "tracking-page-views-in-a-react-spa-with-google-analytics-4": "blog_posts/trackingpage.webp",
}

def fix_covers(apps, schema_editor):
    BlogPost = apps.get_model("blogs", "BlogPost")
    for slug, cover_path in COVER_FIXES.items():
        BlogPost.objects.filter(slug=slug).update(cover=cover_path)

Enter fullscreen mode Exit fullscreen mode

This runs automatically on python manage.py migrate during the next production deploy — no manual SSH step needed.

For the fuzzy matcher, the substring containment fix was patched into restore_covers.py so future runs work correctly for any similarly named files.

For the sixth post, a manual image upload to Django admin is the remaining action item.

What I'd Do Differently

The real issue is that the restore command's failure mode was too quiet. It logged "fuzzy matching won't be available" but still printed a clean summary with zeroes in the "could not restore" column for cases where the file list was empty. That made it look successful.

A better design: if storage.listdir() fails entirely, the command should exit early with a non-zero code rather than continuing with no files to match against. Silently succeeding at nothing is worse than loudly failing at something.

The slug-to-filename mismatch was also a predictable problem from the start. The files were uploaded manually with short descriptive names, but the DB records came from Hashnode with long CUIDs. A mapping file (even a simple JSON dict of slug → filename) would have made the restore command deterministic instead of relying on fuzzy heuristics.