DownloadMarkdown Inline Image Support
> Draft Notice: This proposal is a draft generated by AI and may not have been reviewed for accuracy. It is intended as a starting point for discussion and implementation planning.
Table of Contents
-
Overview
-
Goals
-
Non-Goals
-
Current Behaviour
-
Image Modes
-
Feature 1 ? Remote Image Proxy
- Proxy Endpoint
- Security Constraints
- Caching
- User Preference
-
Feature 2 ? Hosted Image Attachments
- Upload Flow
- Storage
- Serving
- Database Schema
- Compose UI
- Orphan Cleanup
-
MarkdownRenderer Changes
- Distinguishing Images from Links
- imageMode Parameter
- Call Sites
-
Scope ? Where Images Apply
-
Implementation Phases
-
Open Questions
Overview
Markdown image syntax ( ) is currently rendered as a plain hyperlink rather than an <img> tag. This is intentional ? auto-loading remote images leaks the user's IP address to the image host and enables tracking-pixel surveillance.
This proposal adds two complementary features that make inline images work without sacrificing privacy:
-
Remote image proxy ? remote image URLs are rewritten through a server-side proxy endpoint. The user's browser fetches from the BBS server only; it never connects to the remote host.
-
Hosted image attachments ? users can upload images directly when composing an echomail post. Uploaded images are stored on the server and served via a local URL, so no proxy is needed for them.
Both features require a new imageMode flag in MarkdownRenderer so that different call sites (echomail reader, netmail reader, document viewer) can control rendering independently.
Goals
-
Render `
` as a visible image in echomail and netmail message bodies.
-
Never expose the user's IP address to remote image hosts without explicit opt-in.
-
Let users upload images to a post and have them embedded inline.
-
Keep the document viewer unaffected (or explicitly configurable).
-
Give users a preference for how remote images are handled.
Non-Goals
-
Image resizing or thumbnail generation.
-
Hosting images indefinitely ? hosted attachments are tied to the message lifetime.
-
Transmitting embedded images over FTN (images stay on the originating BBS).
-
Supporting `<img>` HTML tags directly in message bodies.
Current Behaviour
MarkdownRenderer::inlineHtml() matches label with the regex:
/\[([^\]]+)\]\(((?:https?:\/\/|\/|#)[^\)]+)\)/
Image syntax  currently has its leading ! stripped before this regex runs (since htmlspecialchars doesn't encode !), so it matches the link pattern and is rendered as <a href="url">alt</a>. The image is never fetched.
Image Modes
A new imageMode parameter controls how  is rendered:
| Mode | Behaviour | Default for |
|------|-----------|-------------|
| link | Render as a hyperlink (current behaviour) | Document viewer; feature disabled |
| proxy | Rewrite remote URLs through /api/image-proxy; render as <img> | Echomail and netmail (recommended default) |
| direct | Render <img src="original-url"> with no rewriting | Opt-in per user preference |
Local URLs (starting with /) are always rendered as <img> regardless of mode ? they are already on the BBS server and carry no privacy risk. This covers hosted attachments.
Feature 1 ? Remote Image Proxy
Proxy Endpoint
GET /api/image-proxy?url=<url-encoded-remote-url>
The server fetches the remote image on the user's behalf and streams it back with the correct Content-Type. The browser sees only a request to the BBS server.
Request handling:
-
Decode and validate the `url` parameter (HTTPS only; reject private ranges ? see Security Constraints).
-
Make a server-side HTTP GET to the remote URL with a short timeout (e.g. 8 seconds).
-
Validate the response `Content-Type` is an image type (`image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml`). Reject anything else with 400.
-
Enforce a maximum response body size (e.g. 5 MB). Abort and return 502 if exceeded.
-
Stream the response body to the client with the original `Content-Type` and a permissive `Cache-Control` (e.g. `public, max-age=3600`).
-
Strip the outbound `Referer` header and set a neutral `User-Agent`.
The rendered <img> tag uses the proxy URL:
<img src="/api/image-proxy?url=https%3A%2F%2Fexample.com%2Fdog.jpg"
alt="A dog" loading="lazy" class="md-image img-fluid">
Security Constraints
The proxy must refuse requests that could be used for server-side request forgery (SSRF):
-
Only `https://` URLs are accepted. Plain `http://` is rejected.
-
Block private IPv4 ranges: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`.
-
Block IPv6 loopback (`::1`) and link-local (`fe80::/10`).
-
Block hostnames that resolve to any of the above ? resolve the hostname server-side before connecting.
-
Reject non-standard ports (allow 80 and 443 only).
-
Do not follow more than one redirect; validate the redirect target against the same rules.
Optionally, a configurable allowlist/blocklist of domains can be added in a future iteration.
Caching
To avoid re-fetching popular images on every page load, the proxy can cache responses on disk under data/image-proxy-cache/ keyed by a SHA-256 hash of the URL:
-
Cache TTL: 1 hour (respect `Cache-Control: max-age` from the upstream if shorter).
-
Maximum cache size: configurable, default 100 MB. Evict by oldest-accessed when full.
-
Cache is optional and can be disabled; the proxy works without it.
A maintenance script or cron job handles cache eviction.
User Preference
A per-user setting under Settings ? Privacy controls how remote images in messages are handled:
| Setting | Behaviour |
|---------|-----------|
| Load via proxy (default) | Images load automatically; IP never exposed to remote hosts |
| Load directly | Images load with original URLs; faster but IP visible to remote host |
| Never load | Images render as hyperlinks (current behaviour) |
The preference is stored in the user settings table and read when constructing the imageMode for message rendering. Admins can set a board-wide default.
Feature 2 ? Hosted Image Attachments
Users composing an echomail post can upload images directly. Uploaded images are stored on the BBS server and served through a local URL, embedding cleanly in the message body with no privacy concern.
Upload Flow
-
User clicks an Insert Image button in the compose toolbar (or drags a file onto the compose area).
-
The browser POSTs the file to `POST /api/echomail-images` (multipart form, requires auth).
-
The server validates the file (type, size), stores it, and returns a JSON response:
{ "url": "/echomail-images/a3f9c2b1d4", "filename": "dog.jpg" }
-
The compose UI inserts `
` at the cursor position.
The image is immediately visible in the markdown preview.
Storage
-
Files are stored in `data/echomail-images/` (outside `public_html`, never served as static files).
-
Filenames on disk are a random 16-character hex token to prevent enumeration.
-
Supported types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`. Reject anything else.
-
Maximum file size: 2 MB per image (configurable via `ECHOMAIL_IMAGE_MAX_BYTES` in `.env`).
-
Maximum images per post: 5 (configurable).
Serving
GET /echomail-images/{token}
A PHP route reads the file from data/echomail-images/, validates the token exists in the database, sets the correct Content-Type, and streams the file. Access is public (images embedded in public message bodies should be viewable by anyone who can read the message).
The token is opaque and non-sequential, making enumeration impractical without a valid token.
Database Schema
CREATE TABLE echomail_images (
id SERIAL PRIMARY KEY,
token VARCHAR(32) NOT NULL UNIQUE, -- random hex, used in URL
uploader_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
echomail_id INTEGER REFERENCES echomail(id) ON DELETE SET NULL,
filename VARCHAR(255) NOT NULL, -- original filename, shown in alt text default
mime_type VARCHAR(64) NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_echomail_images_uploader ON echomail_images(uploader_id);
CREATE INDEX idx_echomail_images_echomail ON echomail_images(echomail_id);
echomail_id is NULL while the post is being composed and is set when the message is saved. This link enables cascade deletion and orphan detection.
Compose UI
-
The compose toolbar gets an image button (e.g. `fa-image`) alongside the existing markup controls.
-
Clicking it opens a file picker. Drag-and-drop onto the textarea also triggers upload.
-
A small progress indicator appears during upload.
-
On success, the markdown tag is inserted at the cursor.
-
On failure, a toast error is shown (file too large, wrong type, server error).
-
The image count badge on the button shows how many images are attached to the current draft (max indicator turns red at the limit).
Orphan Cleanup
Images uploaded but never attached to a message (composer abandoned, post deleted) need periodic cleanup:
-
A maintenance script (`scripts/cleanup_echomail_images.php`) deletes rows where `echomail_id IS NULL` and `created_at < NOW() - INTERVAL '24 hours'`, then deletes the corresponding files on disk.
-
Also deletes rows where `echomail_id` references a deleted message (via the `ON DELETE SET NULL` FK) and `last_used_at < NOW() - INTERVAL '30 days'`.
-
Can be run from cron or added to the admin daemon's maintenance schedule.
MarkdownRenderer Changes
Distinguishing Images from Links
inlineHtml() currently matches label without checking for a preceding !. The regex must be updated to handle both patterns in one pass:
/(!?)\[([^\]]+)\]\(((?:https?:\/\/|\/)[^\)]+)\)/
When the ! capture group is present, the match is image syntax; otherwise it is a link.
imageMode Parameter
toHtml() and inlineHtml() gain an $imageMode parameter (string, one of 'link', 'proxy', 'direct'; default 'link' to preserve current behaviour):
public static function toHtml(string $markdown, int $blockquoteDepth = 0, string $imageMode = 'link'): string
public static function inlineHtml(string $text, string $imageMode = 'link'): string
Inside inlineHtml(), when an image token is detected:
if ($isImage) {
if ($imageMode === 'link') {
// Existing behaviour: render as <a>
$links[$token] = '<a href="' . $url . '"' . $extra . '>' . $label . '</a>';
} elseif (str_starts_with($url, '/')) {
// Local URL (hosted attachment) ? always render as <img>
$links[$token] = '<img src="' . $url . '" alt="' . $label . '" loading="lazy" class="md-image img-fluid">';
} elseif ($imageMode === 'proxy') {
$proxiedUrl = '/api/image-proxy?url=' . urlencode($url);
$links[$token] = '<img src="' . $proxiedUrl . '" alt="' . $label . '" loading="lazy" class="md-image img-fluid">';
} else { // direct
$links[$token] = '<img src="' . $url . '" alt="' . $label . '" loading="lazy" class="md-image img-fluid">';
}
}
Call Sites
| Call site | Suggested imageMode |
|-----------|---------------------|
| Echomail message body | User preference (proxy / direct / link) |
| Netmail message body | User preference (proxy / direct / link) |
| Document viewer (DocsController) | link (documents are local; images are not expected) |
| Admin pages | link |
The user's image_mode preference is read once per request and passed down through MessageHandler to MarkdownRenderer. The default when no preference is set is proxy.
Scope ? Where Images Apply
| Context | Images enabled? | Notes |
|---------|-----------------|-------|
| Echomail message body | Yes | Via proxy or hosted attachment |
| Netmail message body | Yes | Same as echomail |
| Document viewer | No (links only) | Documents are local; images are not expected use case |
| Echo area descriptions | No (links only) | Short text fields; images not appropriate |
| User profile / bio | TBD | Could enable hosted-only (no remote proxy) |
Implementation Phases
Phase 1 ? MarkdownRenderer + Proxy
-
Update `MarkdownRenderer` to distinguish image syntax from link syntax.
-
Add `imageMode` parameter with default `'link'` (no behaviour change for existing call sites).
-
Implement `GET /api/image-proxy` with SSRF protection and content-type validation.
-
Add user preference setting (`proxy` / `direct` / `link`; default `proxy`).
-
Pass `imageMode` to message body rendering in `MessageHandler`.
-
Add `ECHOMAIL_IMAGE_PROXY_ENABLED` env flag (default `true`).
Phase 2 ? Hosted Attachments
-
Database migration for `echomail_images`.
-
`POST /api/echomail-images` upload endpoint.
-
`GET /echomail-images/{token}` serving route.
-
Compose UI: image upload button, drag-and-drop, markdown insertion.
-
Wire `echomail_id` on message save.
-
Maintenance/cleanup script.
Open Questions
-
Should the proxy cache be stored in the database (metadata) or purely on disk?
-
Should hosted images be deleted immediately when the message is deleted, or retained for some grace period in case they are quoted/referenced by other messages?
-
Should netmail images be private (require auth to serve) or public like echomail images?
-
Maximum dimensions or resolution limit for uploaded images, or just file size?
-
Should hosted images count against a user's disk quota in a future storage-quota system?
-
Is SVG a safe image type to proxy/host? (SVGs can contain embedded scripts; they may need to be sanitised or served with `Content-Security-Policy: sandbox`.)
|