Skip to content

External Sources

Pablo Martínez López edited this page Oct 20, 2026 · 3 revisions

External Sources

English · Español

Everything the app downloads from outside Scryfall goes through one hardened HTTP client, fuentes::Externo (src-tauri/src/fuentes/mod.rs): a closed allow-list of sites and path prefixes, a re-check on every redirect, a byte cap enforced while streaming, and images that are always decoded and re-encoded. On top of it sit three data sources: Cockatrice's token database (cards Scryfall does not have), and two name indexes (MTGJSON offline, magicthegathering.io online) used to translate card names from other languages.

Boundaries: Scryfall itself has its own client with its own rules (Scryfall-Integration). Frame packs, fonts and themes use Externo too but are documented with their features (Frame-Packs, Themes-and-Skins).

Files

File Responsibility
src-tauri/src/fuentes/mod.rs PERMITIDOS, permitida(), url_permitida(), Externo (bajar, bajar_como_navegador, bajar_a_fichero), limpiar_imagen(), carta_cockatrice(), buscar_cockatrice(), nombres_ingleses(), resumen_cockatrice(), meta_cockatrice().
src-tauri/src/fuentes/cockatrice.rs Catalogo, FichaCockatrice, catalogo(): parses and indexes tokens.xml.
src-tauri/src/fuentes/mtgjson.rs Offline other-language name index (SQLite), descargar(), info(), quitar(), nombres_ingleses().
src-tauri/src/fuentes/mtgio.rs Online name lookup against magicthegathering.io (last resort).
src-tauri/src/commands/fuentes.rs Settings commands names_index_info, names_index_download, names_index_remove; event names://progress.
src-tauri/src/commands/cards.rs card_fetch_externa, card_search/card_prints (append Cockatrice results), deck_tokens_detect.
src-tauri/src/commands/identify.rs card_link_externa (metadata for linking a custom card to an external token).
src-tauri/src/state.rs AppState.externo: Arc<Externo> (one client for the app).
src/features/settings/nombres-idiomas.tsx Settings UI for the MTGJSON index (download, progress, remove).

Security model

Why it exists (Inferred rationale from the code and its comments; the comments state what is enforced, not a threat model): several of these URLs come from third-party data (for example the image URLs inside Cockatrice's tokens.xml, or a redirect target chosen by a server), so the app must never be steerable to an arbitrary host, never buffer unbounded data, and never store attacker-controlled bytes verbatim.

Decision and mechanism, layer by layer:

  1. Closed list (PERMITIDOS). A URL is allowed only if the scheme is https, there is no explicit port, and the host equals an entry exactly and the path starts with that entry's prefix (permitida()). Exact host equality rejects look-alikes such as i.imgur.com.evil.com; the no-port rule rejects :8443. Test: solo_lo_de_la_lista.
  2. Redirects are re-checked. Externo::nuevo() installs Policy::custom: more than 3 hops is an error; a redirect to an allowed URL is followed; to a disallowed one the redirect is stopped, so the 3xx response comes back and is reported as a failure (non-success status). bajar_con also re-checks the final r.url().
  3. Size cap, streamed. The Content-Length header is checked first when present; then the body is read in chunks and the download is cut as soon as it would exceed tope (InvalidInput, motivo = grande). A server that lies about or omits the length cannot make the app buffer more than the cap.
  4. Bytes are never stored as received. Images pass through limpiar_imagen() (below): decode, then encode again. Catalogues are parsed and only the parsed data is kept; a downloaded file is only saved to disk if it parses (Catalogo::leer(&x)? before escribir_atomico).
  5. Integrity where a hash exists. MTGJSON's zip is checked against the SHA-256 that MTGJSON publishes next to it; fixed-commit downloads of frame packs and fonts check a per-file SHA-256 from the shipped manifests (src-tauri/assets/packs-descargables.json, src-tauri/assets/letras-libres.json, src-tauri/crates/proxy-core/assets/marcos/original/imagenes.json).
  6. Fail soft. Every optional source degrades to "no result" (empty list, Err logged with tracing::warn!) instead of breaking the main flow.

The allow-list

Host Path prefix Used by
raw.githubusercontent.com /Cockatrice/Magic-Token/ Token catalogue (cockatrice.rs)
raw.githubusercontent.com /SlightlyCircuitous/image-storage/ Token images that the catalogue links to
cdn.staticneo.com /w/mtg/ Token images that the catalogue links to
i.imgur.com / Token images that the catalogue links to
api.magicthegathering.io /v1/cards mtgio.rs
raw.githubusercontent.com /PoolFlamingo/proxy-builder-packs/ Downloadable frame packs (diseno/descargables.rs)
mtgjson.com /api/v5/ mtgjson.rs
tweakcn.com /r/themes/ Theme import (temas.rs)
fonts.googleapis.com /css Theme fonts, CSS (bajar_como_navegador)
fonts.gstatic.com /s/ Theme fonts, woff2 files

Adding a source means adding a row here first; there is no other way to reach the network through Externo. url_permitida() also accepts scheme-less //host/path (some catalogues use it) by prefixing https:.

What does not go through Externo

For orientation, other outbound calls have their own guard:

Caller Mechanism
scryfall/client.rs Fixed host api.scryfall.com, rate limiter, retries. Images only from URLs Scryfall returned.
commands/cards.rs import_archidekt Plain reqwest to archidekt.com/api/decks/{id}/ where {id} is a parsed integer (Decklist-Parsing).
ocr.rs descargar_modelos Plain reqwest to the OCR model host; no hash check (Card-Identification-and-OCR).
diseno/original.rs, diseno/letras_libres.rs Plain reqwest to a fixed commit on GitHub, SHA-256 verified per file.

Externo API

Method Behaviour
Externo::nuevo() Builds the client: user agent ProxyBuilder/<version>, 20 s timeout, the redirect policy above.
bajar(url, tope) -> Vec<u8> Allow-list check, GET, status check (NotFound for 404, Network otherwise), size cap, whole body in memory.
bajar_como_navegador(url, tope) Same but sends a desktop-browser user agent; Google Fonts only serves woff2 to browsers it knows.
bajar_a_fichero(url, ruta, tope, progreso) -> sha256 hex Streams to a file with a 600 s timeout, hashes while writing, reports (bytes, total?), deletes the partial file if the cap is exceeded. Used for the 52 MB MTGJSON zip.

Constants: MAX_IMAGEN = 15 MiB, LADO_MAXIMO = 12 000 px, PNG_GRANDE = 2 MiB, output size 745 x 1040 (the size of Scryfall's PNGs).

limpiar_imagen(bytes) - re-encoding

  1. imagesize::blob_size reads the header only; failure -> ErrorCode::Image (motivo = no_es_imagen); either side above 12 000 px -> Image (demasiado_grande), so a decompression bomb is rejected before decoding.
  2. decode::decode (from proxy-core::imaging) fully decodes it to RGBA.
  3. If the input was a JPEG (magic bytes FF D8), or it is "big" (more than 2 MiB or taller than 1500 px): resize to 745 x 1040 with Lanczos3 when larger than that, and encode JPEG quality 95.
  4. Otherwise encode as PNG.

Note that step 3 resizes to the exact 745 x 1040 box (imageops::resize), i.e. a large image is forced to the card aspect rather than letterboxed (Inferred intent: the app stretches images to the card slot, see Imaging-and-PDF). Test: las_imagenes_se_vuelven_a_codificar (PNG stays PNG, JPEG stays JPEG, an HTML file is Image).

Cockatrice tokens (cockatrice.rs)

Cockatrice's Magic-Token repository (tokens.xml) lists roughly a thousand tokens; about 80 of them have no image on Scryfall (for example "Kelp", the 0/1 Plant Wall created by Wall of Kelp). Those are what the app imports.

  • Filter. While parsing, any token with at least one image URL containing scryfall.io is dropped: Scryfall already serves it and it would appear twice. Tokens with no image at all are kept in the catalogue but never offered (every consumer checks !imagenes.is_empty()).
  • FichaCockatrice: id, nombre, tipo, texto, fr (power/toughness), colores, imagenes: Vec<(set, url)>, creadores (the reverse-related cards that create it). linea_de_tipo() prefixes "Token " when missing, like Scryfall's type lines.
  • Stable id. The XML has no ids, so id = first 8 bytes (16 hex chars) of SHA-256 over name, type, text and P/T (huella). It must not change between downloads because decks store it in CardSource::Custom { externa: Some(RefExterna { fuente: Cockatrice, id }) }.
  • Catalogue lifecycle (catalogo()). One Arc<Catalogo> per session in a static Mutex<Option<...>>. Loaded from app_cache_dir/cockatrice-tokens.xml if it is less than 7 days old; otherwise downloaded (cap 8 MiB), validated by parsing, then saved atomically. If the download fails the old file is used; with neither, the error propagates. The cache can be deleted freely.
  • Indexes. By normalised name (exactas) and by normalised creator (creadas_por, which tries the whole card name and each // face). buscar(texto) needs 3+ characters and ranks exact name, prefix, word-prefix, then substring.
  • Parsing is tolerant (roxmltree): missing fields become empty strings; invalid XML is InvalidInput (motivo = cockatrice), test un_xml_roto_no_vale. Fixture: src-tauri/src/fixtures/cockatrice-tokens.xml.

Where Cockatrice tokens appear

Place How
"Add card" search and variants card_search / card_prints append CardSummary rows with fuente = Cockatrice (thumbnail through pbimg://.../t/, downloaded by the protocol handler via Externo). Only for English / unset language and not in high-res-only mode, since Cockatrice images are not high resolution.
Resolving a list Guardar::externa_por_nombre (a line Scryfall could not find but Cockatrice has by exact name) and externas_creadas_por (tokens created by the deck's cards, when "add tokens" is on). Implemented in EnLaApp in commands/cards.rs.
"Detect tokens" deck_tokens_detect also proposes Cockatrice tokens created by cards in the deck, skipping names already present.
Linking own images identify.rs adds exact-name Cockatrice matches to the candidate list; card_link_externa returns the CardMeta for linking (Card-Identification-and-OCR).
Adding the card card_fetch_externa(fuente, id) -> carta_cockatrice().

Turning a catalogue entry into a deck card (carta_cockatrice)

For each image URL of the token, in order: Externo::bajar(url, MAX_IMAGEN), then limpiar_imagen, then blobs.put_bytes(..., ImageOrigin::Upload). The first success returns a DeckCard with CardSource::Custom { scryfall_id: None, externa: Some(...) }, front = the stored image, meta from meta_cockatrice (layout token, language en, set = first image's set code, type line). If every URL fails, the last error is returned, or NotFound with motivo = sin_imagen.

Design note: it is a custom card with an external reference rather than a new CardSource variant, so older app versions open the deck and see an ordinary custom card (Deck-Model). The exporter treats such cards as tokens and leaves them out of text lists (Decklist-Parsing).

Other-language names

Both sources answer one question: given a card name written in language xx ("Muro de algas", language es), what is the English name? The resolver then asks Scryfall for that English name. fuentes::nombres_ingleses(state, nombre, idioma) is the single entry point:

flowchart TD
    A["nombres_ingleses(nombre, idioma)"] --> B{"MTGJSON index downloaded?"}
    B -- yes --> C["mtgjson::nombres_ingleses<br/>SQLite lookup, may be empty"]
    C --> R["return (no further fallback)"]
    B -- no --> D["mtgio::nombres_ingleses<br/>HTTPS, 2 MiB cap, session memo"]
    D --> R
Loading

Note the first branch: when the index exists but has no match, the answer is an empty list; magicthegathering.io is not consulted then.

Callers: resolve.rs step 1b (via Guardar::nombre_ingles, using the first result) and identify.rs when Scryfall finds nothing for the read/typed text.

MTGJSON index (mtgjson.rs)

  • What is stored. From AtomicCards.json.zip (about 52 MB) only (normalised foreign name, Scryfall language code, English name) triples, in app_cache_dir/mtgjson/nombres.sqlite (table nombres, index nombres_extranjero). MTGJSON has no tokens Scryfall lacks, so this is names only. Language names are mapped in codigo() (Spanish, French, German, Italian, Portuguese, Japanese, Korean, Russian, Chinese Simplified/Traditional, Phyrexian).
  • Download pipeline (descargar): fetch AtomicCards.json.zip.sha256 (cap 1 KiB) -> stream the zip to a file with bajar_a_fichero (cap 120 MiB, progress phase Bajando) -> compare the SHA-256 with the published one (mismatch: delete the zip and fail with Network, motivo = huella) -> in spawn_blocking: open the zip, read entry AtomicCards.json with a 1 GiB cap, collect the triples (leer_nombres, phase Leyendo) -> write a temp SQLite file and rename it over the old one (escribir_base) -> delete the zip. The cached read-only connection is dropped (cerrar_base) before the replace.
  • Lookup. SELECT DISTINCT ingles FROM nombres WHERE extranjero = ?1 ORDER BY (idioma = ?2) DESC, ingles: names in the requested language first, but a match in any other language still counts. Returns None (not Some(vec![])) when the file does not exist, which is what makes the caller fall back to the online source.
  • Settings. names_index_info -> NombresIdiomasInfo { descargado, fecha, nombres, tamano } (tamano is a fixed 52 MiB estimate); names_index_download (marks a running job with state.trabajo() and emits names://progress with { fase: bajando|leyendo, hecho, total }); names_index_remove deletes the folder. UI: src/features/settings/nombres-idiomas.tsx.
  • Why optional: the download is large and most users need only English; without it the online fallback still works for lists that use other-language names.

magicthegathering.io (mtgio.rs)

  • Query: GET /v1/cards?name=<text>&language=<lang>&pageSize=10, mapping Scryfall codes to the API's language names (idioma_api; English has none, so it returns nothing).
  • Response is parsed leniently (foreignNames); English names of cards whose foreign name equals the query come first, others after.
  • Results are memoised for the session (cleared at 2 000 entries). No network or a bad answer yields an empty list and a warning; the docs call it slow and unreliable, hence "last resort". Test: el_nombre_ingles_de_la_exacta_primero with fixture src-tauri/src/fixtures/mtgio-muro-de-algas.json.

Failure modes

Condition Result
URL not in the allow-list, bad scheme/port, malformed InvalidInput with motivo = host, url
Body above the cap InvalidInput, motivo = grande
HTTP 404 / other non-2xx / redirect refused NotFound / Network (with url, estado)
Not an image, or sides above 12 000 px Image (motivo = no_es_imagen / demasiado_grande)
Catalogue XML/zip unreadable InvalidInput (motivo = cockatrice / mtgjson)
MTGJSON hash mismatch Network (motivo = huella)
Optional lookup fails (Cockatrice catalogue, mtgio) Logged, treated as no results

Tests

src-tauri/src/fuentes/mod.rs (allow-list, re-encoding), cockatrice.rs (filtering, search, creators, broken XML), mtgio.rs (parsing), mtgjson.rs (indice_de_nombres: build, lookup, fallback language, remove). Live network tests exist but are #[ignore]d (en_vivo modules). The resolver paths that use these sources are tested with fakes in src-tauri/src/scryfall/resolve.rs.

Where to start when extending

  • New downloadable resource: add its host/prefix to PERMITIDOS (test it in solo_lo_de_la_lista), use Externo::bajar with an explicit cap, never write the raw bytes: parse them, or pin and verify a SHA-256.
  • New image source: always route bytes through limpiar_imagen before blobs.put_bytes.
  • New card source: add a FuenteCarta variant (src-tauri/crates/proxy-core/src/model.rs), mirror carta_cockatrice / meta_cockatrice / resumen_cockatrice, and extend card_fetch_externa and card_link_externa.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally