🖼️ Fix: Images Not Displaying on Your Steem Frontend

in #dev10 days ago (edited)

If you're running a Steem frontend, you've probably noticed: Images in posts often don't load. The reason is simple — most Steem posts reference images on steemitimages.com, a CDN proxy that is frequently unreliable, blocks requests from third-party frontends, or simply goes down. The result: Your users see broken image icons instead of content.

We're a big community, and that's exactly why I'm happy to share my findings and solutions with you. If this helps even one fellow witness or developer save a few hours of debugging, it was worth writing up.

Here's a small fix that solves this permanently: A self-hosted image proxy with local caching.

How It Works

Instead of loading images directly from steemitimages.com, your frontend routes all image URLs through your own backend. Your backend fetches the image once, stores it locally, and serves it from cache on every subsequent request. If steemitimages.com goes down, your cached images keep working. This applies to all posts — old and new — because the rewriting happens at display time, not at publish time.

Before:  User → steemitimages.com (unreliable) → broken image
After:   User → your-server/api/images/proxy → cache or fetch → image always works

The Code

1. Backend: Image Proxy Endpoint (Node.js/Express)

const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const router = express.Router();

const CACHE_DIR = process.env.IMAGE_CACHE_DIR || './image-cache';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days

// Create cache directory
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });

// Allowed image sources (add more as needed)
const ALLOWED_DOMAINS = [
  'steemitimages.com',
  'cdn.steemitimages.com',
  'images.hive.blog',
  'i.imgur.com',
  'imgur.com',
  'ipfs.io',
  'img.steemconnect.com',
  'steemit.com'
];

// SSRF protection — block local/private addresses
function isPrivateUrl(urlStr) {
  try {
    const u = new URL(urlStr);
    const host = u.hostname;
    return host === 'localhost' ||
           host === '127.0.0.1' ||
           host.startsWith('10.') ||
           host.startsWith('192.168.') ||
           host.startsWith('172.') ||
           host === '0.0.0.0';
  } catch { return true; }
}

router.get('/api/images/proxy', async (req, res) => {
  const imageUrl = req.query.url;
  if (!imageUrl) return res.status(400).send('Missing url parameter');

  // Validate URL
  try {
    const parsed = new URL(imageUrl);
    if (!ALLOWED_DOMAINS.some(d => parsed.hostname.endsWith(d))) {
      return res.status(403).send('Domain not allowed');
    }
    if (isPrivateUrl(imageUrl)) {
      return res.status(403).send('Private addresses not allowed');
    }
  } catch {
    return res.status(400).send('Invalid URL');
  }

  // Cache key = SHA256 hash of URL
  const hash = crypto.createHash('sha256').update(imageUrl).digest('hex');
  const metaPath = path.join(CACHE_DIR, `${hash}.meta`);
  const dataPath = path.join(CACHE_DIR, `${hash}.data`);

  // Serve from cache if available and fresh
  if (fs.existsSync(dataPath) && fs.existsSync(metaPath)) {
    try {
      const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
      const age = Date.now() - meta.cachedAt;
      if (age < CACHE_MAX_AGE) {
        res.set('Content-Type', meta.contentType);
        res.set('Cache-Control', 'public, max-age=604800');
        res.set('X-Cache', 'HIT');
        return res.sendFile(path.resolve(dataPath));
      }
    } catch { /* cache corrupt, re-fetch */ }
  }

  // Fetch from source
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout

    const response = await fetch(imageUrl, {
      signal: controller.signal,
      headers: { 'User-Agent': 'SteemFrontend-ImageProxy/1.0' }
    });
    clearTimeout(timeout);

    if (!response.ok) {
      // If we have a stale cache, serve it
      if (fs.existsSync(dataPath)) {
        const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
        res.set('Content-Type', meta.contentType);
        res.set('X-Cache', 'STALE');
        return res.sendFile(path.resolve(dataPath));
      }
      return res.status(502).send('Source unavailable');
    }

    const contentType = response.headers.get('content-type') || '';
    if (!contentType.startsWith('image/')) {
      return res.status(415).send('Not an image');
    }

    const buffer = Buffer.from(await response.arrayBuffer());
    if (buffer.length > MAX_FILE_SIZE) {
      return res.status(413).send('Image too large');
    }

    // Save to cache
    fs.writeFileSync(dataPath, buffer);
    fs.writeFileSync(metaPath, JSON.stringify({
      contentType,
      originalUrl: imageUrl,
      cachedAt: Date.now(),
      size: buffer.length
    }));

    res.set('Content-Type', contentType);
    res.set('Cache-Control', 'public, max-age=604800');
    res.set('X-Cache', 'MISS');
    return res.send(buffer);

  } catch (err) {
    // Timeout or network error — serve stale cache if available
    if (fs.existsSync(dataPath)) {
      try {
        const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
        res.set('Content-Type', meta.contentType);
        res.set('X-Cache', 'STALE');
        return res.sendFile(path.resolve(dataPath));
      } catch { /* fall through */ }
    }
    return res.status(502).send('Source unavailable');
  }
});

module.exports = router;

2. Backend: Image Proxy Endpoint (.NET/C#)

using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;

[ApiController]
[Route("api/images")]
public class ImageProxyController : ControllerBase
{
    private static readonly string CacheDir = 
        Environment.GetEnvironmentVariable("IMAGE_CACHE_DIR") ?? "/app/image-cache";
    
    private static readonly HashSet<string> AllowedDomains = new()
    {
        "steemitimages.com", "cdn.steemitimages.com", "images.hive.blog",
        "i.imgur.com", "imgur.com", "ipfs.io", "img.steemconnect.com", "steemit.com"
    };
    
    private static readonly HttpClient Http = new()
    {
        Timeout = TimeSpan.FromSeconds(10),
        DefaultRequestHeaders = { { "User-Agent", "SteemFrontend-ImageProxy/1.0" } }
    };

    public ImageProxyController()
    {
        Directory.CreateDirectory(CacheDir);
    }

    [HttpGet("proxy")]
    public async Task<IActionResult> Proxy([FromQuery] string url)
    {
        if (string.IsNullOrEmpty(url)) return BadRequest("Missing url");

        // Validate
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return BadRequest("Invalid URL");
        if (!AllowedDomains.Any(d => uri.Host.EndsWith(d))) return StatusCode(403, "Domain not allowed");
        if (IsPrivate(uri.Host)) return StatusCode(403, "Private address");

        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(url));
        var hashStr = Convert.ToHexString(hash).ToLower();
        var dataPath = Path.Combine(CacheDir, $"{hashStr}.data");
        var metaPath = Path.Combine(CacheDir, $"{hashStr}.meta");

        // Serve from cache
        if (System.IO.File.Exists(dataPath) && System.IO.File.Exists(metaPath))
        {
            try
            {
                var metaJson = await System.IO.File.ReadAllTextAsync(metaPath);
                var meta = System.Text.Json.JsonSerializer.Deserialize<CacheMeta>(metaJson);
                var age = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - meta.CachedAt;
                if (age < 7 * 24 * 60 * 60 * 1000) // 7 days
                {
                    Response.Headers["X-Cache"] = "HIT";
                    Response.Headers["Cache-Control"] = "public, max-age=604800";
                    return PhysicalFile(Path.GetFullPath(dataPath), meta.ContentType);
                }
            }
            catch { /* stale cache, re-fetch */ }
        }

        // Fetch from source
        try
        {
            var response = await Http.GetAsync(url);
            if (!response.IsSuccessStatusCode)
                return await ServeStaleOrError(dataPath, metaPath);

            var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
            if (!contentType.StartsWith("image/"))
                return StatusCode(415, "Not an image");

            var bytes = await response.Content.ReadAsByteArrayAsync();
            if (bytes.Length > 10 * 1024 * 1024)
                return StatusCode(413, "Too large");

            await System.IO.File.WriteAllBytesAsync(dataPath, bytes);
            await System.IO.File.WriteAllTextAsync(metaPath, 
                System.Text.Json.JsonSerializer.Serialize(new CacheMeta
                {
                    ContentType = contentType,
                    OriginalUrl = url,
                    CachedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
                    Size = bytes.Length
                }));

            Response.Headers["X-Cache"] = "MISS";
            Response.Headers["Cache-Control"] = "public, max-age=604800";
            return File(bytes, contentType);
        }
        catch
        {
            return await ServeStaleOrError(dataPath, metaPath);
        }
    }

    private async Task<IActionResult> ServeStaleOrError(string dataPath, string metaPath)
    {
        if (System.IO.File.Exists(dataPath) && System.IO.File.Exists(metaPath))
        {
            var meta = System.Text.Json.JsonSerializer.Deserialize<CacheMeta>(
                await System.IO.File.ReadAllTextAsync(metaPath));
            Response.Headers["X-Cache"] = "STALE";
            return PhysicalFile(Path.GetFullPath(dataPath), meta.ContentType);
        }
        return StatusCode(502, "Source unavailable");
    }

    private static bool IsPrivate(string host) =>
        host is "localhost" or "127.0.0.1" or "0.0.0.0" ||
        host.StartsWith("10.") || host.StartsWith("192.168.") || host.StartsWith("172.");

    private class CacheMeta
    {
        public string ContentType { get; set; }
        public string OriginalUrl { get; set; }
        public long CachedAt { get; set; }
        public int Size { get; set; }
    }
}

3. Frontend: Rewrite Image URLs (works with any framework)

This is the key piece. When rendering Markdown/HTML from Steem posts, all image URLs get rewritten to go through your proxy. Put this wherever you convert post body to HTML:

/**
 * Rewrites all image URLs in rendered HTML to use the local proxy.
 * Call this AFTER your Markdown-to-HTML conversion.
 */
function proxyImages(html) {
  return html.replace(
    /<img\s+([^>]*?)src="(https?:\/\/[^"]+)"([^>]*?)>/gi,
    (match, before, src, after) => {
      // Skip already proxied URLs and data URIs
      if (src.includes('/api/images/proxy') || src.startsWith('data:')) return match;
      const proxied = `/api/images/proxy?url=${encodeURIComponent(src)}`;
      return `<img ${before}src="${proxied}"${after}>`;
    }
  );
}

// Usage example:
// const rawHtml = markdownToHtml(post.body);
// const safeHtml = proxyImages(rawHtml);
// element.innerHTML = safeHtml;

Vue.js example:

<template>
  <div v-html="renderedBody"></div>
</template>
<script>
export default {
  props: ['post'],
  computed: {
    renderedBody() {
      const html = this.markdownToHtml(this.post.body);
      return this.proxyImages(html);
    }
  },
  methods: {
    proxyImages(html) {
      return html.replace(
        /<img\s+([^>]*?)src="(https?:\/\/[^"]+)"([^>]*?)>/gi,
        (match, before, src, after) => {
          if (src.includes('/api/images/proxy') || src.startsWith('data:')) return match;
          return `<img ${before}src="/api/images/proxy?url=${encodeURIComponent(src)}"${after}>`;
        }
      );
    }
  }
}
</script>

React example:

function PostBody({ post }) {
  const html = useMemo(() => {
    const raw = markdownToHtml(post.body);
    return raw.replace(
      /<img\s+([^>]*?)src="(https?:\/\/[^"]+)"([^>]*?)>/gi,
      (match, before, src, after) => {
        if (src.includes('/api/images/proxy') || src.startsWith('data:')) return match;
        return `<img ${before}src="/api/images/proxy?url=${encodeURIComponent(src)}"${after}>`;
      }
    );
  }, [post.body]);

  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

4. Cache Cleanup (Cronjob)

#!/bin/bash
# /opt/your-app/scripts/clean-image-cache.sh
# Run daily: 0 3 * * * /opt/your-app/scripts/clean-image-cache.sh

CACHE_DIR="/path/to/image-cache"
MAX_AGE_DAYS=14
MAX_SIZE_GB=5

# Delete files older than 14 days
find "$CACHE_DIR" -type f -mtime +$MAX_AGE_DAYS -delete

# If still over limit, delete oldest first
CURRENT_SIZE=$(du -sb "$CACHE_DIR" 2>/dev/null | cut -f1)
MAX_BYTES=$(( MAX_SIZE_GB * 1024 * 1024 * 1024 ))
if [ "${CURRENT_SIZE:-0}" -gt "$MAX_BYTES" ]; then
  ls -1tr "$CACHE_DIR" | head -1000 | while read f; do
    rm -f "$CACHE_DIR/$f"
  done
fi

Security Notes

  • Domain whitelist: Only proxy images from known Steem image hosts. This prevents abuse.

  • SSRF protection: Block localhost, 127.0.0.1, and private IP ranges. Without this, attackers could use your proxy to scan your internal network.

  • Content-Type validation: Only serve actual images. Reject anything that isn't image/*.

  • Size limit: Cap at 10 MB per image to prevent disk abuse.

  • Timeout: 10 seconds max per fetch. Don't let slow sources hang your server.

That's It

Three components: A backend endpoint that fetches and caches, a frontend function that rewrites URLs, and a cronjob that cleans up. Works retroactively on all existing posts because the rewriting happens at display time. If steemitimages.com goes down, your cached images keep working.

Feel free to adapt this to your stack. If you have questions, reach out — I'm happy to help.


Support My Work

If you found this useful and want to support the development of tools like these for the Steem ecosystem, I'd appreciate your witness vote. I'm currently ranked #52 and every vote helps me keep building.

👉 Vote for @greece-lover as Witness

Just scroll down to the input field at the bottom, type greece-lover and click Vote. Thank you!

@greece-lover — Steem Witness #52




Veröffentlicht mit SteemFront

Coin Marketplace

STEEM 0.06
TRX 0.29
JST 0.056
BTC 70656.88
ETH 2078.87
USDT 1.00
SBD 0.52