Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
27f20f1
feat(ui): inscription-page UI overhaul + nav improvements
natebitsats May 16, 2026
b5cda33
feat(ui): theme switcher, gallery item page, copy buttons, hover-only…
natebitsats May 19, 2026
f417dee
feat(inscriptions): sort dropdown (newest / oldest) on /inscriptions
natebitsats May 19, 2026
261b148
feat(inscriptions): filter toolbar — cursed toggle, range, satributes…
natebitsats May 19, 2026
4f1d4be
refactor(inscriptions): filter toolbar as design-only; revert backend…
natebitsats May 19, 2026
41bda8c
fix(chain): lift testnet4 1 KB inscription size cap
natebitsats May 21, 2026
f9154b9
fix(wallet): drop label on commit-tx recovery-key descriptor
natebitsats May 21, 2026
80e31d8
feat(ui): titles in page headings + parent-child breadcrumb trail
natebitsats May 22, 2026
31bd7c0
feat(ui): text-content titles, breadcrumb reinscription dropdowns, mo…
natebitsats May 22, 2026
378d523
fix(ui): center breadcrumb dropdown caret
natebitsats May 22, 2026
e1a61c6
fix(ui): breadcrumb caret colour and shape
natebitsats May 22, 2026
422bb7a
feat(ui): marketplace dropdown, breadcrumb children, theme/museum cle…
natebitsats May 26, 2026
04a74d1
feat: /preview content-type fallback via magic-byte sniffing
natebitsats May 26, 2026
ec51dd1
feat(ui): collapse multi-trail breadcrumbs into prefix + fork + suffix
natebitsats May 26, 2026
a3f596d
feat(ui): chain-aware home text + Gallery superscript
natebitsats May 26, 2026
c084fed
feat(ui): nested breadcrumb tree, popout dropdowns, mobile horizontal…
natebitsats May 26, 2026
9f9d809
style(ui): nav icon order + remove thumbnail borders
natebitsats May 26, 2026
7907496
feat(ui): show children in the current crumb's dropdown too
natebitsats May 26, 2026
fc3ee4f
fix(ui): restore galleries icon position; transparent preview-image bg
natebitsats May 26, 2026
e1b0297
fix(ui): always render a breadcrumb (even for root inscriptions)
natebitsats May 26, 2026
f7098a4
fix(ui): make active nav-link text theme-aware
natebitsats May 26, 2026
ec11303
fix(ui): hide breadcrumb when inscription has no parents AND no children
natebitsats May 26, 2026
acd62e7
perf(ui): render image inscriptions as native img thumbnails in grids
natebitsats Jul 25, 2026
573a72e
fix(ui): apply image-rendering via class — CSP blocks inline styles
natebitsats Jul 25, 2026
4920c07
feat(ui): cursed filter, museum coming-soon page, toolbar polish
natebitsats Jul 25, 2026
60f13ef
fix(ui): coming soon page — top-aligned heading with full stop
natebitsats Jul 25, 2026
b1b699c
fix(ui): add viewport meta to preview templates
natebitsats Jul 25, 2026
751bae4
fix(ui): render image previews via img element, not background-image
natebitsats Jul 26, 2026
bc75f71
perf(ui): content-visibility auto on grid cells
natebitsats Jul 26, 2026
6b88aa9
fix(ui): square grid cells at the cell, not the img
natebitsats Jul 27, 2026
bfc696b
fix(ui): script-less sandbox for HTML inscription thumbnails
natebitsats Jul 27, 2026
4d03a9f
fix(ui): render thumbnail iframes at 4x and scale down
natebitsats Jul 27, 2026
5402d4a
fix(ui): absolutely position scaled thumbnail iframes
natebitsats Jul 27, 2026
19b7a14
fix(ui): block-display preview image — inline baseline gap caused scr…
natebitsats Jul 27, 2026
faf6fd5
fix(server): never serve the host machine's name to clients
natebitsats Aug 2, 2026
32cd34f
fix(ui): render script-driven thumbnails without unbounding CPU
natebitsats Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

[Unreleased]
------------

### Added
- Rescue inscriptions with a wrong `content_type` field via body magic-byte sniffing on `/preview/<id>`. Recognised formats: glTF/GLB, glTF+json, PNG, JPEG, GIF, WebP, MP4 (brand-filtered), Ogg, WAV, FLAC, and PDF. Brotli-encoded bodies are transparently decoded behind a 4 KiB cap. The MP4 matcher excludes HEIC/HEIF and QuickTime; no format is auto-upgraded to `Media::Iframe`. `/content/<id>`, `/embed/<id>`, thumbnails, and oEmbed are unaffected. See `docs/src/inscriptions/rendering.md` § Content-Type Fallback.

[0.27.1](https://github.com/ordinals/ord/releases/tag/0.27.1) - 2026-03-10
--------------------------------------------------------------------------

Expand Down
58 changes: 58 additions & 0 deletions docs/src/inscriptions/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,61 @@ When upscaling AVIF and JPEG XL inscriptions, `image-rendering: auto` should be
used. This allows inscribers to opt-in to non-pixelated upscaling for non-pixel
art inscriptions. Until such time as JPEG XL is widely supported by browsers,
it is not a recommended image format.

Content-Type Fallback
---------------------

A number of inscriptions on Bitcoin were created with the wrong `content_type`
field — typically `application/octet-stream` for a payload that is in fact a
glTF model, PNG image, MP4 video, and so on. The on-chain state is immutable
and cannot be amended.

To preserve user-facing rendering for these inscriptions, the `/preview/<id>`
endpoint inspects the first 4 KiB of the inscription body (transparently
decoding brotli when `content_encoding == "br"`) and upgrades the inferred
`Media` based on recognised magic-byte signatures. The behaviour is invoked
only when the stored `content_type` would otherwise resolve to
`Media::Unknown`; correctly-tagged inscriptions are routed exactly as before.

The following formats are detected, in this order:

| Bytes inspected | Format | Rendered as |
| ----------------------------------------- | --------- | ------------------------ |
| `glTF` at offset 0 | GLB | `Model` (model-viewer) |
| `{` followed by `"asset"` and `"version"` | glTF JSON | `Model` (model-viewer) |
| `\x89PNG\r\n\x1a\n` at offset 0 | PNG | `Image(Pixelated)` |
| `\xff\xd8\xff` at offset 0 | JPEG | `Image(Pixelated)` |
| `GIF87a` or `GIF89a` at offset 0 | GIF | `Image(Pixelated)` |
| `RIFF????WEBP` | WebP | `Image(Pixelated)` |
| `ftyp` at offset 4, MP4-family brand | MP4 | `Video` |
| `OggS` at offset 0 | Ogg | `Audio` |
| `RIFF????WAVE` | WAV | `Audio` |
| `fLaC` at offset 0 | FLAC | `Audio` |
| `%PDF-` at offset 0 | PDF | `Pdf` |

The MP4 matcher brand-filters to `{mp4*, iso*, avc1, M4V , dash}`. HEIC, HEIF,
and QuickTime share the ISO-BMFF `ftyp` header but are deliberately rejected
so they fall through to the unknown preview rather than being mis-promoted to
`Media::Video`.

No format is auto-upgraded to `Media::Iframe`. Untrusted HTML or SVG bytes
arriving under a wrong content-type label are intentionally left as
`Media::Unknown`, because rendering arbitrary inscriptions inside an iframe
would expand the renderable surface beyond the on-chain content-type contract
and carries CSP and sandbox implications.

The brotli decode step is bounded by a hard `.take(4 KiB)` cap, so a
maliciously crafted small compressed payload cannot trigger an unbounded
decompression. Encodings other than brotli are not decoded; the raw bytes are
sniffed as-is and will simply fail to match any rule.

This rescue layer applies only to `/preview/<id>`. The `/content/<id>`
endpoint continues to serve the original on-chain `Content-Type` header,
leaving downstream clients (`<model-viewer>`, `<img>`, `<video>`) to do their
own content sniffing if they wish. Embed, thumbnail, and oEmbed responses
are not covered.

Implementation: `Media::from_body_head` and the `SNIFF_TABLE` constant in
`src/inscriptions/media.rs`; `Inscription::sniffed_media` in
`src/inscriptions/inscription.rs`; one call site in the `/preview/<id>`
handler in `src/subcommand/server.rs`.
7 changes: 5 additions & 2 deletions src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ impl Chain {

pub(crate) fn inscription_content_size_limit(self) -> Option<usize> {
match self {
Self::Mainnet | Self::Regtest => None,
Self::Testnet | Self::Testnet4 | Self::Signet => Some(1024),
// Testnet4 limit lifted locally: in practice everyone bypasses the 1KB
// cap on testnet4, so it no longer functions as spam protection — and
// we need real-size inscriptions to dry-run MoBA galleries publicly.
Self::Mainnet | Self::Regtest | Self::Testnet4 => None,
Self::Testnet | Self::Signet => Some(1024),
}
}

Expand Down
126 changes: 110 additions & 16 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2023,28 +2023,122 @@ impl Index {
&self,
page_size: u32,
page_index: u32,
sort: crate::templates::inscriptions::Sort,
) -> Result<(Vec<InscriptionId>, bool)> {
use crate::templates::inscriptions::Sort;

let rtx = self.database.begin_read()?;

let sequence_number_to_inscription_entry =
rtx.open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ENTRY)?;

let last = sequence_number_to_inscription_entry
.iter()?
.next_back()
.map(|result| result.map(|(number, _entry)| number.value()))
.transpose()?
.unwrap_or_default();
let mut inscriptions = match sort {
Sort::Newest => {
let last = sequence_number_to_inscription_entry
.iter()?
.next_back()
.map(|result| result.map(|(number, _entry)| number.value()))
.transpose()?
.unwrap_or_default();

let start = last.saturating_sub(page_size.saturating_mul(page_index));
let start = last.saturating_sub(page_size.saturating_mul(page_index));
let end = start.saturating_sub(page_size);

let end = start.saturating_sub(page_size);
sequence_number_to_inscription_entry
.range(end..=start)?
.rev()
.map(|result| result.map(|(_number, entry)| InscriptionEntry::load(entry.value()).id))
.collect::<Result<Vec<InscriptionId>, StorageError>>()?
}
Sort::Oldest => {
let start = page_size.saturating_mul(page_index);
let end = start.saturating_add(page_size);

sequence_number_to_inscription_entry
.range(start..=end)?
.map(|result| result.map(|(_number, entry)| InscriptionEntry::load(entry.value()).id))
.collect::<Result<Vec<InscriptionId>, StorageError>>()?
}
};

let mut inscriptions = sequence_number_to_inscription_entry
.range(end..=start)?
.rev()
.map(|result| result.map(|(_number, entry)| InscriptionEntry::load(entry.value()).id))
.collect::<Result<Vec<InscriptionId>, StorageError>>()?;
let more = u32::try_from(inscriptions.len()).unwrap_or(u32::MAX) > page_size;

if more {
inscriptions.pop();
}

Ok((inscriptions, more))
}

pub fn get_cursed_inscriptions_paginated(
&self,
page_size: u32,
page_index: u32,
sort: crate::templates::inscriptions::Sort,
) -> Result<(Vec<InscriptionId>, bool)> {
use crate::templates::inscriptions::Sort;

let rtx = self.database.begin_read()?;

let inscription_number_to_sequence_number =
rtx.open_table(INSCRIPTION_NUMBER_TO_SEQUENCE_NUMBER)?;
let sequence_number_to_inscription_entry =
rtx.open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ENTRY)?;

// Cursed inscriptions are numbered densely from -1 downward, so page
// bounds can be computed arithmetically instead of skipping.
let lowest = i64::from(
inscription_number_to_sequence_number
.iter()?
.next()
.map(|result| result.map(|(number, _)| number.value()))
.transpose()?
.unwrap_or(0),
);

if lowest >= 0 {
return Ok((Vec::new(), false));
}

let page_size_i = i64::from(page_size);
let skip = page_size_i * i64::from(page_index);

let sequence_numbers: Vec<u32> = match sort {
// Most negative number = most recently inscribed cursed.
Sort::Newest => {
let start = lowest.saturating_add(skip);
if start >= 0 {
return Ok((Vec::new(), false));
}
let end = start.saturating_add(page_size_i + 1).min(0);
inscription_number_to_sequence_number
.range(i32::try_from(start).unwrap()..i32::try_from(end).unwrap())?
.map(|result| result.map(|(_, sequence)| sequence.value()))
.collect::<Result<Vec<u32>, StorageError>>()?
}
Sort::Oldest => {
let hi = -1_i64 - skip;
if hi < lowest {
return Ok((Vec::new(), false));
}
let lo = (hi - page_size_i).max(lowest);
inscription_number_to_sequence_number
.range(i32::try_from(lo).unwrap()..=i32::try_from(hi).unwrap())?
.rev()
.map(|result| result.map(|(_, sequence)| sequence.value()))
.collect::<Result<Vec<u32>, StorageError>>()?
}
};

let mut inscriptions = sequence_numbers
.into_iter()
.map(|sequence| {
sequence_number_to_inscription_entry
.get(&sequence)?
.map(|entry| InscriptionEntry::load(entry.value()).id)
.ok_or_else(|| anyhow!("missing inscription entry for sequence number {sequence}"))
})
.collect::<Result<Vec<InscriptionId>>>()?;

let more = u32::try_from(inscriptions.len()).unwrap_or(u32::MAX) > page_size;

Expand Down Expand Up @@ -2281,7 +2375,7 @@ impl Index {
let child_count = all_children.len();

let children = all_children
.take(4)
.take(100)
.map(|result| {
result
.and_then(|sequence_number| {
Expand Down Expand Up @@ -3926,7 +4020,7 @@ mod tests {

context.mine_blocks(1);

let (inscriptions, more) = context.index.get_inscriptions_paginated(100, 0).unwrap();
let (inscriptions, more) = context.index.get_inscriptions_paginated(100, 0, crate::templates::inscriptions::Sort::Newest).unwrap();
assert_eq!(inscriptions, &[inscription_id]);
assert!(!more);
}
Expand All @@ -3953,7 +4047,7 @@ mod tests {

assert_eq!(ids.len(), 100);

let (inscriptions, more) = context.index.get_inscriptions_paginated(100, 0).unwrap();
let (inscriptions, more) = context.index.get_inscriptions_paginated(100, 0, crate::templates::inscriptions::Sort::Newest).unwrap();
assert_eq!(inscriptions, ids);
assert!(more);
}
Expand Down
116 changes: 116 additions & 0 deletions src/inscriptions/inscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,55 @@ impl Inscription {
content_type.parse().unwrap_or(Media::Unknown)
}

/// Like `Self::media`, but if the result is `Media::Unknown` and the body
/// is non-empty, peek at the first 4 KiB of the body (transparently
/// decoding brotli when `content_encoding == "br"`) and try to upgrade the
/// classification via `Media::from_body_head`.
///
/// This is the entry point the `/preview/<id>` handler uses to rescue
/// inscriptions that were inscribed with the wrong (or missing)
/// `content_type` tag — most commonly `application/octet-stream` for what
/// is actually a glTF, PNG, JPEG, MP4, etc.
///
/// On-chain state is untouched; only the preview-template choice changes.
/// The `/content/<id>` endpoint continues to serve the original on-chain
/// `Content-Type` header, leaving downstream clients (`<model-viewer>`,
/// `<img>`, `<video>`) to do their own content sniffing if they wish.
/// Embed, thumbnail, and oEmbed paths are not covered by this method.
pub fn sniffed_media(&self) -> Media {
let media = self.media();
if !matches!(media, Media::Unknown) {
return media;
}
let Some(body) = self.body() else { return media };
let head = self.sniff_head(body);
Media::from_body_head(&head).unwrap_or(Media::Unknown)
}

/// Return up to 4 KiB from the start of the body for magic-byte inspection.
///
/// When `content_encoding == "br"` the body is brotli-decoded with a hard
/// `.take(4 KiB)` cap, so a maliciously crafted small compressed payload
/// cannot fan out into a multi-gigabyte decompression — the same DoS shape
/// that the server's `--decompress` flag warns about.
///
/// Encodings other than brotli are not decoded; the raw bytes are passed
/// through, and will simply fail to match any magic-byte rule.
fn sniff_head(&self, body: &[u8]) -> Vec<u8> {
const SNIFF_BYTES: usize = 4096;
match self.content_encoding.as_deref() {
Some(b"br") => {
use std::io::Read;
let mut head = Vec::with_capacity(SNIFF_BYTES);
let _ = brotli::Decompressor::new(body, SNIFF_BYTES)
.take(SNIFF_BYTES as u64)
.read_to_end(&mut head);
head
}
_ => body[..body.len().min(SNIFF_BYTES)].to_vec(),
}
}

pub fn body(&self) -> Option<&[u8]> {
Some(self.body.as_ref()?)
}
Expand Down Expand Up @@ -1415,4 +1464,71 @@ mod tests {
.is_none()
);
}

#[test]
fn sniffed_media_passes_through_known_content_type() {
let i = Inscription {
content_type: Some(b"model/gltf+json".into()),
body: Some(b"not even valid gltf".to_vec()),
..default()
};
assert_eq!(i.sniffed_media(), Media::Model);
}

#[test]
fn sniffed_media_upgrades_octet_stream_glb() {
let mut body = b"glTF".to_vec();
body.extend_from_slice(&[2, 0, 0, 0, 0, 0, 0, 0]);
let i = Inscription {
content_type: Some(b"application/octet-stream".into()),
body: Some(body),
..default()
};
assert_eq!(i.sniffed_media(), Media::Model);
}

#[test]
fn sniffed_media_upgrades_octet_stream_gltf_json() {
let i = Inscription {
content_type: Some(b"application/octet-stream".into()),
body: Some(br#"{"asset":{"version":"2.0"},"scene":0}"#.to_vec()),
..default()
};
assert_eq!(i.sniffed_media(), Media::Model);
}

#[test]
fn sniffed_media_upgrades_brotli_compressed_gltf() {
let gltf = br#"{"asset":{"version":"2.0"},"scene":0,"scenes":[{"nodes":[]}],"nodes":[]}"#;
let mut compressed = Vec::new();
CompressorWriter::new(&mut compressed, BROTLI_BUFFER_SIZE, 11, 22)
.write_all(gltf)
.unwrap();

let i = Inscription {
content_type: Some(b"application/octet-stream".into()),
content_encoding: Some(b"br".to_vec()),
body: Some(compressed),
..default()
};
assert_eq!(i.sniffed_media(), Media::Model);
}

#[test]
fn sniffed_media_leaves_random_bytes_unknown() {
let i = Inscription {
content_type: Some(b"application/octet-stream".into()),
body: Some(vec![0xde, 0xad, 0xbe, 0xef]),
..default()
};
assert_eq!(i.sniffed_media(), Media::Unknown);
}

#[test]
fn sniffed_media_no_body_is_unknown() {
let i = Inscription {
..default()
};
assert_eq!(i.sniffed_media(), Media::Unknown);
}
}
Loading