Skip to content

Releases: Dim145/FigureCollector

0.48.4 — a security pass

Choose a tag to compare

@Dim145 Dim145 released this 24 Sep 20:41

A security pass

A patch on top of 0.48.3: every open alert on the repository's Security tab, plus a manual review of the code. Upgrading is recommended, especially if you run a 3D-scan (splat) worker.

Fixed — scan storage trusted paths a worker could rewrite

The splat workers report their results by updating the scans row directly in Postgres, with their own credentials. The server then took two of that row's columns — where the scan's files live, and which file is the trained model — at face value, both when serving a scan and when deleting one.

So anything able to write that table could make the server stream, or delete, any object in the bucket: another user's invoices, their photos, the frames of someone else's scan. That takes database write access, which in practice means a compromised or hostile worker, so this is not reachable by an ordinary user of the app. Workers on hardware you don't fully control are the case to worry about.

A scan's files are now always located from the scan's own id, the same way they were laid out at creation; the columns are still written for the workers, but the server no longer believes them.

Two storage leaks close with it: deleting a scan from its page left a 3D scan's model and capture video in the bucket, and .m4v capture videos were never purged at all.

Fixed — dependencies

  • All eleven Dependabot alerts in the web client: adm-zip, sharp, browserslist, baseline-browser-mapping, fast-uri, vitest. The adm-zip symlink advisory still lists "no fix"; 0.6.1 does contain it, under a different commit than the one the advisory cites.
  • Two Rust vulnerabilities Dependabot never raised, found with cargo audit: rustls (TLS 1.3 handshake messages accepted across encryption levels — it carries every outbound TLS connection the server makes) and crossbeam-epoch. Plus two crates flagged unsound and four yanked ones.

Every Rust bump was surgical, and verified with the locked release build rather than a host check.

Hardening

None of these was exploitable as found; each removes a way a future change could make it so.

  • Pasted shop URLs. An orzgk wishlist URL was fetched exactly as pasted once its host checked out, so the port and scheme stayed the caller's to choose — enough to make the server probe ports on a third party. It's now rebuilt from its path, like product URLs already were.
  • Outbound address filter. User-chosen destinations (webhooks, ntfy, Apprise, push endpoints, MangaCollector servers) now also refuse 0.0.0.0/8, and IPv4 addresses embedded in IPv6 through NAT64, which on a NAT64 network can reach the cloud metadata service.
  • Links from third-party data. A crafted tracking link could reach the page as a javascript: URL. React 19 already neutralises those at render time, so this was never exploitable, but the app now enforces http(s) itself on every scraped or externally-sourced link rather than relying on that.
  • Headers. The Content-Security-Policy gains object-src 'none', and the last proxied route now overwrites a client-supplied X-Forwarded-For like all the others.
  • Supply chain. Every GitHub Action the build runs is pinned to a commit SHA, since a tag like @v4 can be moved by whoever controls the action, and the release workflow publishes these images.

The CodeQL alert (#9) was a false positive; its bound is now explicit in the code where the analyser can see it.


🔐 Security contract
🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.48.4
📝 Full changelog: 0.48.3...0.48.4

0.48.3 — numbers that say what they leave out

Choose a tag to compare

@Dim145 Dim145 released this 13 Sep 23:52

Numbers that say what they leave out

A patch on top of 0.48.2, from two test reports run against the live endpoint.

Fixed — your collection was valued at prices nobody is asking any more

The value chain took the last price a shop was seen asking, however long ago it said it. On a real collection two isolated observations — three months old, from the source that matches no product version — carried about 497 € of a 832 € reported plus-value, against pieces whose catalogue list prices totalled 214 €.

The sweep revisits a figure every couple of days, so a price it hasn't refreshed in thirty days doesn't mean the piece held its value: it means the listing stopped resolving — delisted, sold out, renamed. Such a price now falls through to the catalogue MSRP, exactly like a figure that was never priced.

Expect a lower collection value after this update. It is the truer one. valuation.pieces_stale counts the pieces it happened to, so the drop explains itself — and a large count is telling you the price sweep has stopped reaching those shops, which is the actual news.

Fixed — a wishlist row can now answer its own question

A row handed back a target in one currency and a shop price in another, and left the comparison to whoever was reading. The web app manages; an assistant over MCP has no rate table at all. And the bare numbers mislead in both directions: $149.99 under a €150.00 target looks like a one-cent deal and is a twenty-euro one, while $239 against a €200 target reads as over by $39 and is over by €6.

Each row now carries target_comparison: whether the target is met, which price was measured, both sides in euros when a conversion happened, and the rate date so the verdict can be checked later. Absent when there's no target, no price, or no rate for the pair — an honest "can't tell" rather than a wrong answer.

Fixed — statistics that contradicted each other

  • Pre-order slip. Slip was the sum of forward date changes, with backward ones dropped. That's right for a reschedule and wrong for a correction: type a purchase date into the release-date field, fix it seconds later, and the repair counts as a slip. One pre-order that really slipped 62 days reported 335 — while the year-in-review card reported 62 for the same pre-order. Both now measure announced date to current date, and a corrected typo is no longer a permanent mark on that maker's record.
  • Deposits at risk. An open pre-order with 108.00 committed showed no deposits, beside an "open: 1" that was right. The query required the price's currency, and a pre-order normally records only the deposit — the shop invoices the balance months later — so it filtered out exactly the rows the panel exists to show.
  • Plus-value. It is measured against what you paid for the figures, not your total outlay; the two differ by 400 € on a real collection, and nothing said which. plus_value_basis now does.

Fixed — duplicate detection defeated by word order

find_duplicate_figures matched on substring, so "Crown Studio 1:6 Tifa" found the entry and "Crown Studio Tifa 1:6" found nothing. Shop titles are exactly where word order wanders. It now matches on word sets, with the furniture dropped (【PRE-ORDER】, "resin", "statue", a bare scale), which matters because this is the check that runs before a new catalogue row is created for a figure that already exists.

Also

Price alerts kept a permanent dedup key, which with the crossing-based alerts introduced in 0.48.2 would have silently swallowed a genuine second crossing months later. Two MCP tool descriptions pointed at a tool that doesn't exist, and one didn't mention that permanent deletion needs its own scope — which read as "deletion isn't available over MCP" when it is.


📘 Wishlist · La Cote · Pre-orders
🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.48.3
📝 Full changelog: 0.48.2...0.48.3

0.48.2 — the MCP endpoint answers again

Choose a tag to compare

@Dim145 Dim145 released this 10 Sep 08:39

The MCP endpoint answers again — and the numbers say what they count

A patch on top of 0.48.1. Everything here came out of a test report against the live endpoint.

Fixed — ten MCP tools were unusable

Asking an assistant to list your collection, browse the catalogue's makers, read your timeline or match a list of names came back as "tool execution failed" with nothing else to go on.

It was not the database, the migrations or the query text. structuredContent carried the raw serialised value, so a tool returning a list produced a top-level JSON array — and the protocol revisions in use type that field as a record. The server was emitting something invalid and the client had no way to say which side was wrong. Tools that happened to wrap their result in a struct worked, which is exactly what made it look like a data problem.

The shape is now normalised once at the boundary rather than per tool, so it cannot diverge again: a list answers {count, items}, and every list-bearing result states its own count.

Two neighbours of the same defect: find_figure_by_barcode answered a miss with a bare null — indistinguishable from a tool that produced nothing — and now says {found, figure}. And search_catalogue / get_activity accept limit and offset but could not tell a caller more rows existed, so a full page read as the whole answer; they now carry a has_more that is exact.

Fixed — four statistics meant something other than their name

  • Pre-orders "Total" was computed identically to "En cours", so the stats screen contradicted your own achievements, and the pre-order export card under-counted. It counts every pre-order ever placed now.
  • Plus-value compared what you paid against a value that, with nothing valued by hand, is the shop's asking price or the manufacturer's list price — for a full-price purchase, mechanically ~0. The totals now carry a valuation block naming the tier behind them.
  • Series completion counts entries in this instance's catalogue, not everything a maker released. 100% means "I have entered every piece of this series that I own", and the payload says so.
  • Pre-order slip stats claimed makers below the sample floor were dropped. Only the per-maker list dropped them; the overall figure reported a median off a single slip. Each row now carries reliable.

Fixed — a year's pieces landed in the wrong year

Year-in-review counted spend from the purchase date but counted pieces, the top maker, the monthly curve and first/last acquisition from when the row was typed into the app. A piece bought in 2025 and entered in 2026 landed in two different years, so a year could report €1,150 spent and 0 pieces acquired. Anything predating the activity feed, or imported, counted nowhere at all.

Changed — price alerts fire on the crossing

An alert used to key on the price level, so any movement below your target counted as a fresh event: a shop wobbling €161.19 → €160.85 → €161.19 pinged three times, and one figure could produce ten notifications for a target it had crossed once. You are now told once per crossing, and again if the price climbs back above and drops. The notification also carries how the comparison was made — basis, both sides in EUR, and the rate date — and a figure every shop reports out of stock no longer alerts, since the restock alert already covers the moment it becomes buyable.

Added — duplicate makers can be prevented and undone

A maker is created from free text and matched by slug, so "Crown Studio" and "CROWN Studio (new)" became two rows and split every per-maker statistic between them. Series and characters have had a merge path for as long as their screens existed; manufacturers had none. Creating a near-identical maker over MCP is now refused with the existing spelling named (overridable for a genuinely different company), and an administrator can fold two that already exist together.


📘 MCP documentation · Wishlist alerts · La Cote
🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.48.2
📝 Full changelog: 0.48.1...0.48.2

0.48.1 — modal focus fix + generic MCP client config

Choose a tag to compare

@Dim145 Dim145 released this 05 Sep 12:09

Modal focus fix + a config block for every other client

A patch on top of 0.48.0.

Fixed

Modals stole focus back on every keystroke. Typing in a text field inside any modal moved focus to the dialog's close button after each character — the character landed correctly first, so what you saw was a caret vanishing mid-word and the Tab order restarting from the top.

useFocusTrap listed onClose in its effect dependencies, and every caller passes an inline arrow. So any parent re-render — a keystroke updating the field's state, for instance — produced a new function identity, tore the effect down and set it up again, and the setup re-focuses the first tab stop. The callback now lives in a ref, so focus is placed once per open rather than once per render.

This affected every modal in the app, not just the new one. The three that could hit it in practice — the ones pairing a text input with an inline onClose — were the API-key dialog, the figure lookup and admin settings.

The API-key dialog now also opens focused on its name field instead of on the close button.

Docs

The MCP setup section only showed claude mcp add, which covers exactly one client. It now carries the generic mcpServers JSON block (Cursor, Windsurf, Cline, OpenCode, Claude Desktop), the VS Code variant under servers, and the mcp-remote bridge for clients that still launch servers as a subprocess — plus the two traps worth knowing: keeping the key out of a committed .vscode/mcp.json, and why the mcp-remote header argument has no space after its colon.


📘 MCP documentation
🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.48.1
📝 Full changelog: 0.48.0...0.48.1

0.48.0 — hand your shelf to an assistant

Choose a tag to compare

@Dim145 Dim145 released this 04 Sep 23:04

Hand your shelf to an assistant

One feature, but a new surface. FigureCollector now speaks the Model Context Protocol at /mcp, so an AI client can read the catalogue and curate your collection directly — no copy-pasting screenshots into a chat window.

Which series am I closest to finishing? What's slipping, and whose dates should I stop trusting? What should I buy with 200 €, landed cost included?

Getting started

  • Mint a key under Réglages → Accès API (鍵), pick a preset, paste the claude mcp add command the dialog hands you.
  • Works with any MCP client that can send a custom header — Claude Code, Claude Desktop, Cursor, VS Code. claude.ai web connectors require OAuth discovery and won't connect to a static key.

Keys you can reason about

  • One per client, named, revocable on its own, optionally expiring.
  • Scopes you tick. Read-only is the default — widening a key is a deliberate click, not something you fail to notice.
  • The secret is shown once and stored nowhere; the prefix stays visible so you can tell your keys apart.

You can see what it did

Every tool call, resource read and refusal is logged and shown back to you in the same panel: when, which tool, the outcome, which key. Arguments are stored as a digest, never verbatim — they carry prices, private notes and shop names.

Guardrails

  • Administration is out of reach, not merely gated. No scope opens instance administration, account and privacy settings, share-link minting or outbound scraping — and that holds for an administrator's own key, which can edit exactly the catalogue entries any other user's could.
  • Deletion is not the default answer. It needs its own scope and an explicit confirmation on the call, and the refusal points at archiving, which is reversible.
  • Catalogue text is fenced as untrusted. Much of it was scraped or entered by other users; results mark it as data to report on rather than instructions to follow, so a figure description can't quietly redirect an agent.
  • Rate-limited per key rather than per IP, so one agent's retry loop can't throttle everyone behind the same NAT.
  • An admin can close the endpoint instance-wide, though it ships open. Existing keys survive and resume when it reopens.

Also included

Six ready-made prompts (audit, what to buy, pre-order briefing, insurance prep, year in review, series gaps), and your collection exposed as readable resources an assistant can attach to a conversation.


📘 Documentation
🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.48.0
📝 Full changelog: 0.47.0...0.48.0

0.47.0 — read what was already there

Choose a tag to compare

@Dim145 Dim145 released this 23 Aug 00:28

Read what was already there

The biggest release since 0.40 — nineteen items, most of them turning data the app had been recording for months into something you can actually act on.

Acquiring

  • Back-in-stock alerts — a shop that quietly restocks a wished figure now wakes you, not just a price drop. Availability shows on every wishlist row, with an in stock lens.
  • Price-floor radar — per-row market history: how far today sits above the cheapest price ever seen, and how long it has held.
  • Landed cost — what an import really costs once VAT, duty and the carrier's clearance fee land. Operator-maintained rule table; no tax API, nothing about what you buy leaves the instance.
  • MyFigureCollection lookups work again behind a Cloudflare solver — and the importer now reads release date, price, barcode, scale and height, which it had always been silently missing.

Pre-orders

  • Cashflow plan — 12 months of the balance still to pay, against an optional monthly ceiling.
  • Slip radar — median and P80 lateness per maker, from your own history, with the sample count shown.
  • Arrival QC — dated condition reports, a defect log, and countdowns on the shop's DOA window and the carrier's claim window that warn you before they close.

Your collection

  • Search and sort /collection, filters in the URL, scroll position restored when you come back from a piece.
  • Plate density — Auto · Comfort · Dense · Contact sheet, and covers that morph into the detail page.
  • Item and box graded separately (A+ → J) plus completeness.
  • Insurance coverage — the share of your collection's value backed by a receipt, priciest gaps first.

Data & offline

  • Restore a backup — backup.json re-imports, with a dry-run preview and one transaction.
  • Offline, for real — an on-device mirror answers do I already have this? from a barcode scan with no signal, and offline additions queue up and dispatch on reconnect.

Under the hood

  • Background removal moved to BiRefNet (MIT), served from your own instance: stronger on hair and translucent parts, no third-party CDN, works offline.
  • Notifications that used to arrive as raw JSON now read as sentences, deep-link to the piece, and follow your language.

🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.47.0
📝 Full changelog: 0.46.5...0.47.0

0.46.5 — h2 DoS fix + nginx security headers

Choose a tag to compare

@Dim145 Dim145 released this 22 Aug 13:22

Security + hardening

  • server — h2 0.4.14 → 0.4.18, patching RUSTSEC-2026-0258 (HTTP/2 unbounded empty DATA frames — DoS). Surgical bump: sqlx/pgvector unchanged, verified with the --release --locked build.
  • client + docs (nginx) — security headers (CSP et al.) are now re-applied on asset / service-worker / manifest responses. nginx replaces inherited add_header in any block that adds its own, so those responses had been served without the security headers.

Other RustSec transitive advisories remain accepted (no safe upstream fix; vulnerable paths unreachable here — a blanket cargo update breaks pgvector/sqlx, so they need a coordinated bump). See SECURITY-SCAN-REPORT.md.


🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.46.5
📝 Full changelog: 0.46.4...0.46.5

0.46.4 — dependency security bumps

Choose a tag to compare

@Dim145 Dim145 released this 22 Aug 02:44

Dependency security bumps

Ships the fixes from e812929 into the published images (no functional change).

  • client — dompurify 3.4.12 → 3.4.14 (bundled into the runtime via jspdf); build tooling fast-uri → 4.1.2, nanoid → 3.3.18, brace-expansion → 5.0.9.
  • docs — pymdown-extensions → 11.0.1, mkdocs-material floor → 9.7 (9.6 caps pymdown at <11).

Resolves Dependabot alerts GHSA-7p8r-x3mc-p8w7 (fast-uri), GHSA-55q2-fjhq-7xh7 (dompurify), GHSA-gm37-52c6-37mw (pymdown-extensions), plus two latent audit highs — GHSA-2v37-7h3g-55p8 (nanoid) and GHSA-rgw5-rvv9-x895 (brace-expansion). pnpm audit clean · client build + 43 tests green · docs resolve verified on PyPI.


🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.46.4
📝 Full changelog: 0.46.3...0.46.4

0.46.3 — single-result search fix

Choose a tag to compare

@Dim145 Dim145 released this 30 Jul 08:26

Bug fix — single-result orzgk searches

When an orzgk search matches exactly one figure, the store redirects straight to that product page, so the search parser (which looks for result cards) found nothing and the lone result vanished. The importer now detects that redirect (via the product page's /product/ canonical) and rebuilds the single result from the product page. A genuine zero-result search still returns empty — no regression.

Also: internal test + design-mockup sample data cleanup (no functional/runtime change).


🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.46.3
📝 Full changelog: 0.46.2...0.46.3

0.46.2 — Cloudflare clearance reuse

Choose a tag to compare

@Dim145 Dim145 released this 29 Jul 16:40

Cloudflare clearance reuse (faster orzgk scraping)

The orzgk solver client now reuses the Cloudflare cf_clearance cookie across requests instead of re-solving the challenge on every call.

  • The first request to a host solves via the configured solver (FlareSolverr / Byparr) and harvests cf_clearance + the solver's exact User-Agent.
  • Subsequent requests to the same host replay them on a plain direct GET (no browser) — falling back to the solver only if Cloudflare challenges again (cookie expired / rotated), so it's never worse than before.
  • A burst to one host (wishlist pagination, the price cron) now costs one solve plus cheap replays instead of one ~30s solve each. A per-host lock collapses concurrent cache-misses into a single solve.

Works with every drop-in — notably Byparr, which (unlike FlareSolverr) has no server-side sessions, so the reuse is done client-side. No config or API changes.

Verified live (Byparr + orzgk): first request solved in ~24s, the next replayed in ~10s with the solver seeing exactly one challenge solve.


🐳 ghcr.io/dim145/figurecollector-{server,client,docs}:0.46.2
📝 Full changelog: 0.46.1...0.46.2