Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NFT: add data-url support; add text & json nft support #317

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/sage-assets/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ futures-util = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
mime-sniffer = { workspace = true }
base64 = "0.21"
percent-encoding = "2.3"
90 changes: 89 additions & 1 deletion crates/sage-assets/src/nfts/fetch_nft_uri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use mime_sniffer::MimeTypeSniffer;
use reqwest::header::CONTENT_TYPE;
use thiserror::Error;
use tracing::debug;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use percent_encoding::percent_decode;

const MAX_TEXT_SIZE: usize = 10 * 1024 * 1024; // 10MB limit for text content

#[derive(Debug, Clone)]
pub struct Data {
Expand All @@ -30,10 +34,40 @@ pub enum UriError {

#[error("No URIs provided")]
NoUris,

#[error("Invalid data URI format")]
InvalidDataUri,

#[error("Base64 decode error: {0}")]
Base64DecodeError(#[from] base64::DecodeError),

#[error("Text content too large")]
TextContentTooLarge,

#[error("Invalid UTF-8 encoding")]
InvalidUtf8Encoding,
}

fn validate_text_content(content: &[u8]) -> Result<(), UriError> {
// Check size limit
if content.len() > MAX_TEXT_SIZE {
return Err(UriError::TextContentTooLarge);
}

// Validate UTF-8
String::from_utf8(content.to_vec())
.map_err(|_| UriError::InvalidUtf8Encoding)?;

Ok(())
}

pub async fn fetch_uri(uri: String) -> Result<Data, UriError> {
let response = reqwest::get(uri).await?;
// Check if it's a data URI
if uri.starts_with("data:") {
return parse_data_uri(&uri);
}

let response = reqwest::get(&uri).await?;

let mime_type = match response.headers().get(CONTENT_TYPE) {
Some(header) => Some(
Expand All @@ -56,6 +90,60 @@ pub async fn fetch_uri(uri: String) -> Result<Data, UriError> {
.to_string()
};

// Validate text content if mime type is text/plain
if mime_type == "text/plain" {
validate_text_content(&blob)?;
}

let mut hasher = Sha256::new();
hasher.update(&blob);
let hash = Bytes32::new(hasher.finalize());

Ok(Data {
blob,
mime_type,
hash,
})
}

fn parse_data_uri(uri: &str) -> Result<Data, UriError> {
// Remove "data:" prefix
let content = uri.strip_prefix("data:").ok_or(UriError::InvalidDataUri)?;

// Split into metadata and data parts
let parts: Vec<&str> = content.split(',').collect();
if parts.len() != 2 {
return Err(UriError::InvalidDataUri);
}

let (metadata, data) = (parts[0], parts[1]);

// Parse mime type and encoding
let (mime_type, is_base64) = if metadata.ends_with(";base64") {
(metadata[..metadata.len() - 7].to_string(), true)
} else {
(metadata.to_string(), false)
};

// Decode the data
let blob = if is_base64 {
BASE64.decode(data)?
} else {
// For non-base64, handle percent encoding
percent_decode(data.as_bytes())
.decode_utf8()
.map_err(|_| UriError::InvalidDataUri)?
.to_string()
.as_bytes()
.to_vec()
};

// Validate text content if mime type is text/plain
if mime_type == "text/plain" {
validate_text_content(&blob)?;
}

// Calculate hash
let mut hasher = Sha256::new();
hasher.update(&blob);
let hash = Bytes32::new(hasher.finalize());
Expand Down
1 change: 1 addition & 0 deletions crates/sage/src/sage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ impl Sage {
fs::create_dir_all(&path)?;
let network_id = &self.config.network.network_id;
let path = path.join(format!("{network_id}.sqlite"));
info!("Database path: {}", path.display());
Ok(path)
}

Expand Down
13 changes: 6 additions & 7 deletions src/components/NftCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { z } from 'zod';
import { AssignNftDialog } from './AssignNftDialog';
import ConfirmationDialog from './ConfirmationDialog';
import { FeeOnlyDialog } from './FeeOnlyDialog';
import { NftPreview } from './NftPreview';
import { TransferDialog } from './TransferDialog';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
Expand Down Expand Up @@ -218,13 +219,11 @@ const NftCardComponent = ({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<img
alt={nft.name ?? t`NFT artwork for unnamed NFT`}
loading='lazy'
width='150'
height='150'
className='h-auto w-auto object-cover transition-all group-hover:scale-105 aspect-square color-[transparent]'
src={nftUri(data?.mime_type ?? null, data?.blob ?? null)}
<NftPreview
data={data}
name={nft.name}
compact={true}
className="rounded-lg"
/>
</TooltipTrigger>
<TooltipContent>
Expand Down
185 changes: 185 additions & 0 deletions src/components/NftPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { NftData } from '@/bindings';
import { isImage, isVideo, isText, isJson, nftUri } from '@/lib/nftUri';
import { cn } from '@/lib/utils';
import { t } from '@lingui/core/macro';
import { useEffect, useRef } from 'react';

interface NftPreviewProps {
data: NftData | null;
compact?: boolean;
className?: string;
name?: string | null;
}

// Count emoji sequences as single characters
const getVisualLength = (str: string) => {
return str.replace(
/\p{Emoji_Modifier_Base}\p{Emoji_Modifier}|\p{Emoji}(\u200d\p{Emoji})*|\p{Emoji}/gu,
'_',
).length;
};

export function NftPreview({
data,
compact = false,
className,
name,
}: NftPreviewProps) {
const textRef = useRef<HTMLPreElement>(null);
const uri = nftUri(data?.mime_type ?? null, data?.blob ?? null);

// Dynamic text sizing effect for plain text and JSON
useEffect(() => {
const el = textRef.current;
if (el && (isText(data?.mime_type) || isJson(data?.mime_type))) {
const { width, height } = el.getBoundingClientRect();

// Get content dimensions
const content = el.textContent || '';
const lines = content.split('\n');
const maxLineLength = Math.max(
...lines.map((line) => getVisualLength(line)),
);
const lineCount = lines.length;

// Container dimensions in character units (assuming 16px per char)
const containerColumns = width / 16;
const containerRows = height / 16;

// Calculate scaling factors based on content
const contentColumns = maxLineLength;
const contentRows = lineCount;

// Scale based on both container and content
const columnScale = containerColumns / contentColumns;
const rowScale = containerRows / contentRows;

// Base scale (from ord)
const baseScale = compact ? 40 : 95;

// For multi-line content, we should be more conservative with height scaling
const heightAdjustment = lineCount > 1 ? 0.45 : 1; // Reduce scale for multi-line content

// Apply the most constraining scale
const scale = Math.min(
baseScale,
baseScale * columnScale,
baseScale * rowScale * heightAdjustment,
);

// Use container dimensions instead of viewport units
const containerWidth = compact ? 400 : 900;
const containerHeight = compact ? 400 : 900;

el.style.fontSize = `min(${(scale * containerWidth) / (100 * containerColumns)}px, ${(scale * containerHeight) / (100 * containerRows)}px)`;
el.style.opacity = '1';

// Special case for very short content
if (contentColumns <= 4 && contentRows <= 4) {
const singleCharScale = compact ? 140 : 500;
el.style.fontSize = `min(${(singleCharScale * containerWidth) / (100 * containerColumns)}px, ${(singleCharScale * containerHeight) / (100 * containerRows)}px)`;
}

/*
console.log('Content Analysis:', {
type: isJson(data?.mime_type) ? 'JSON' : 'Text',
compact,
content,
lineCount,
maxLineLength,
visualLength: getVisualLength(content),
containerSize: { width, height },
fontSize: el.style.fontSize,
baseScale: baseScale,
columnScale: columnScale,
rowScale: rowScale,
heightAdjustment,
finalScale: scale,
containerDims: { containerWidth, containerHeight },
});*/
}
}, [data?.mime_type, compact]);

if (isImage(data?.mime_type ?? null)) {
return (
<img
alt={name ?? t`NFT artwork for unnamed NFT`}
loading='lazy'
width='150'
height='150'
className={cn(
'h-auto w-auto object-cover transition-all aspect-square color-[transparent]',
compact && 'group-hover:scale-105',
className,
)}
src={uri}
/>
);
}

if (isVideo(data?.mime_type ?? null)) {
return (
<video
src={uri}
controls
className={cn(
'h-auto w-auto object-cover transition-all aspect-square',
compact && 'group-hover:scale-105',
className,
)}
/>
);
}

if (isJson(data?.mime_type ?? null) || isText(data?.mime_type ?? null)) {
const content = isJson(data?.mime_type ?? null)
? JSON.stringify(JSON.parse(uri), null, 2)
: uri;

return (
<div
className={cn(
'grid h-full w-full place-items-center overflow-hidden',
className,
)}
style={{
gridTemplate: '1fr / 1fr',
height: compact ? '150px' : '400px',
}}
>
<pre
ref={textRef}
className={cn(
'm-0 p-2 whitespace-pre-wrap break-words',
isJson(data?.mime_type)
? 'font-mono text-left'
: 'font-sans text-center',
'bg-white border border-neutral-200 rounded-lg transition-all',
compact && 'group-hover:scale-105',
)}
style={{
gridColumn: '1 / 1',
gridRow: '1 / 1',
width: compact ? '150px' : '400px',
opacity: 0, // Start invisible until sized
maxHeight: compact ? '150px' : '80vh',
}}
>
{content}
</pre>
</div>
);
}

// Fallback for unsupported types
return (
<div
className={cn(
'flex items-center justify-center aspect-square bg-gray-100 text-gray-400',
className,
)}
>
<span className='text-sm'>{t`Unsupported content type`}</span>
</div>
);
}
27 changes: 27 additions & 0 deletions src/lib/nftUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,28 @@ const imageTypes = [

const videoTypes = ['video/webm', 'video/mp4'];

const textTypes = ['text/plain'];

export function nftUri(mimeType: string | null, data: string | null): string {
if (data === null || mimeType === null) return missing;

if (textTypes.includes(mimeType) || isJson(mimeType)) {
try {
// Try to decode as base64 first
const binaryStr = atob(data);
// Convert binary string to Uint8Array for proper UTF-8 handling
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
// Decode as UTF-8
return new TextDecoder().decode(bytes);
} catch {
// If decoding fails, assume it's already plain text
return data;
}
}

if (!imageTypes.concat(videoTypes).includes(mimeType)) return invalid;

return `data:${mimeType};base64,${data}`;
Expand All @@ -26,3 +45,11 @@ export function isImage(mimeType: string | null): boolean {
export function isVideo(mimeType: string | null): boolean {
return mimeType !== null && videoTypes.includes(mimeType);
}

export function isText(mimeType: string | null): boolean {
return mimeType === 'text/plain';
}

export function isJson(mimeType: string | null): boolean {
return mimeType === 'application/json' || mimeType?.endsWith('+json');
}
Loading