Commit Graph
2122 Commits
Author SHA1 Message Date
SnapOtter a76982da60 chore: reset the partial 2.2.0 release artifacts for a clean re-run
The first v2.2.0 dispatch failed at archive-security after semantic-release had
already committed its release artifacts (chore(release): 2.2.0). Undo those so
the re-dispatch regenerates them once instead of duplicating the CHANGELOG
section: restore .release-notes.md to the root, and drop the 2.2.0 entries from
CHANGELOG.md and the docs changelog. The tag and draft release were deleted, so
the fresh run re-tags on this commit, which includes the #665 fixes the previous
tag lacked. Non-releasable type so the re-run stays 2.2.0.
2026-07-29 19:08:52 +08:00
SnapOtterandGitHub 5c75a93484 ci(release): fix the archive tsx path and patch the go-tools x/text HIGH (#665)
Two latent bugs the v2.2.0 release run surfaced, both added by #649 and never run
in a real release (v2.1.0 had no archive-security job).

archive-security asserted tsx at the workspace root, but tsx is a prod dependency
of apps/api, so pnpm places its bin at apps/api/node_modules/.bin/tsx, where the
Docker CMD runs it. The root path never existed and failed the extract step on
both arches. Fixed to the apps/api path, proven against the real prebuilt-amd64
artifact.

The blocking Trivy scans would then have failed on CVE-2026-56852,
golang.org/x/text v0.38.0 -> v0.39.0, the only fixed CRITICAL/HIGH in the image,
compiled into caire and pdfcpu. Pinned to v0.39.0 in both go-tools modules,
verified building in the golang:1.25.12 toolchain with -mod=readonly and linking
v0.39.0, with a clean Trivy rescan.

Guards added for both the tsx path and the x/text pin. Non-releasable type so a
re-dispatch re-runs 2.2.0.
2026-07-29 19:05:30 +08:00
semantic-release-bot 4ae03b441b chore(release): 2.2.0 [skip ci]
# [2.2.0](https://github.com/snapotter-hq/snapotter/compare/v2.1.0...v2.2.0) (2026-07-29)

### Bug Fixes

* **a11y:** focus indicators meet the 3:1 non-text contrast bar ([#574](https://github.com/snapotter-hq/snapotter/issues/574)) ([6339370](https://github.com/snapotter-hq/snapotter/commit/63393700939e34ad3d847043b03728e3c2907445)), closes [#A85518](https://github.com/snapotter-hq/snapotter/issues/A85518) [#F0A766](https://github.com/snapotter-hq/snapotter/issues/F0A766)
* **a11y:** WCAG AA contrast retune for the Otter Orange palette ([#567](https://github.com/snapotter-hq/snapotter/issues/567)) ([5102262](https://github.com/snapotter-hq/snapotter/commit/51022628dc19d8a03525bab98473f3c23b1a2709)), closes [#E07832](https://github.com/snapotter-hq/snapotter/issues/E07832) [#1A1814](https://github.com/snapotter-hq/snapotter/issues/1A1814)
* **ai-bridge:** surface sidecar exit reasons in Sentry via SafeError ([#538](https://github.com/snapotter-hq/snapotter/issues/538)) ([55e1e95](https://github.com/snapotter-hq/snapotter/commit/55e1e95f20792cae4f1f2257bafe839b83332c93))
* **ai-bundles:** stop CPU onnxruntime from clobbering onnxruntime-gpu ([#544](https://github.com/snapotter-hq/snapotter/issues/544)) ([c8629c9](https://github.com/snapotter-hq/snapotter/commit/c8629c9d22b455b6deac206e3816ae0f730f06c6)), closes [#490](https://github.com/snapotter-hq/snapotter/issues/490)
* **ai:** advance the progress bar during upscale and background removal ([#608](https://github.com/snapotter-hq/snapotter/issues/608)) ([e56edc6](https://github.com/snapotter-hq/snapotter/commit/e56edc659f742430798104b52a08806a8d8fbff2)), closes [#591](https://github.com/snapotter-hq/snapotter/issues/591)
* **ai:** warn that upscale and background removal are slow without a GPU ([#605](https://github.com/snapotter-hq/snapotter/issues/605)) ([d43208b](https://github.com/snapotter-hq/snapotter/commit/d43208b85e1ba1f0ac687970b8fb52ca1738ae15)), closes [#591](https://github.com/snapotter-hq/snapotter/issues/591)
* **api:** contain library stored-name path traversal ([#600](https://github.com/snapotter-hq/snapotter/issues/600)) ([4333432](https://github.com/snapotter-hq/snapotter/commit/43334324c446c53eb1dc2f8ef14cbecca4e2a676))
* **api:** enforce job ownership on cancel endpoint ([#599](https://github.com/snapotter-hq/snapotter/issues/599)) ([577d74b](https://github.com/snapotter-hq/snapotter/commit/577d74bdb134d9a7d02c27ba976731839d071e63))
* **api:** gate every tool endpoint and stop ZIP streams failing quietly ([#646](https://github.com/snapotter-hq/snapotter/issues/646)) ([2d8b57c](https://github.com/snapotter-hq/snapotter/commit/2d8b57c57fad4e1d98ab7eb327198809aa64bd09)), closes [#645](https://github.com/snapotter-hq/snapotter/issues/645) [#643](https://github.com/snapotter-hq/snapotter/issues/643) [#645](https://github.com/snapotter-hq/snapotter/issues/645)
* **api:** wait for Postgres and Redis at startup instead of crash-looping ([#537](https://github.com/snapotter-hq/snapotter/issues/537)) ([4ac89fe](https://github.com/snapotter-hq/snapotter/commit/4ac89fe6505e50645174e8de5fcdc40c46420cc4))
* **audio:** expose sample rate setting in Convert Audio ([#561](https://github.com/snapotter-hq/snapotter/issues/561)) ([d4eaa65](https://github.com/snapotter-hq/snapotter/commit/d4eaa655b24236da5633006871415afbf0c92d08)), closes [#558](https://github.com/snapotter-hq/snapotter/issues/558)
* **auth:** close the MFA policy lockout and add self-service enrollment ([#531](https://github.com/snapotter-hq/snapotter/issues/531)) ([190d4c2](https://github.com/snapotter-hq/snapotter/commit/190d4c2a002c6d3d240ff18480e37217fcbeae12)), closes [#529](https://github.com/snapotter-hq/snapotter/issues/529) [#515](https://github.com/snapotter-hq/snapotter/issues/515)
* **auth:** give OIDC/SAML logins a real MFA challenge instead of a hard block ([#536](https://github.com/snapotter-hq/snapotter/issues/536)) ([bbfcbe9](https://github.com/snapotter-hq/snapotter/commit/bbfcbe9c8212afb244bb2738a6e3643d06dcee68)), closes [#533](https://github.com/snapotter-hq/snapotter/issues/533)
* **background-removal:** surface failures with a diagnosable SafeError ([#535](https://github.com/snapotter-hq/snapotter/issues/535)) ([43ddf96](https://github.com/snapotter-hq/snapotter/commit/43ddf96ccf7baf94416050ec6229efca68124679))
* **ci:** repair the chronically-failing nightly workflow ([#624](https://github.com/snapotter-hq/snapotter/issues/624)) ([44f5aea](https://github.com/snapotter-hq/snapotter/commit/44f5aea3268f022641832af4b8c60cbd2d5e2c7e))
* **compress-pdf:** land close to the target size, honestly ([#522](https://github.com/snapotter-hq/snapotter/issues/522)) ([7d938af](https://github.com/snapotter-hq/snapotter/commit/7d938af1f9e601238fd49af4c2cd157d16616266))
* **deps:** patch 4 HIGH/CRITICAL transitive CVEs (fast-uri, svgo, sharp, tar) ([#619](https://github.com/snapotter-hq/snapotter/issues/619)) ([10a2aab](https://github.com/snapotter-hq/snapotter/commit/10a2aabe589b5afd470f6c47ccdb1bf8bca15c02))
* **doc-engine:** guard sidecar JSON parsing against non-JSON stdout ([#532](https://github.com/snapotter-hq/snapotter/issues/532)) ([a2cb1a8](https://github.com/snapotter-hq/snapotter/commit/a2cb1a8261b2669ae7e656a418999b70a37f6b4f))
* **docker:** harden Postgres readiness checks in compose and startup ([#595](https://github.com/snapotter-hq/snapotter/issues/595)) ([4ba7503](https://github.com/snapotter-hq/snapotter/commit/4ba7503f15a4444c4937576dbcadafdb19a8848d)), closes [#592](https://github.com/snapotter-hq/snapotter/issues/592)
* **docs:** keep the nav within the viewport on tablets and laptops ([#570](https://github.com/snapotter-hq/snapotter/issues/570)) ([69f72a6](https://github.com/snapotter-hq/snapotter/commit/69f72a6c712512e8489b23bcf78a233d872d897c)), closes [#556](https://github.com/snapotter-hq/snapotter/issues/556)
* **docs:** keep the translated locale trees out of the search index ([#662](https://github.com/snapotter-hq/snapotter/issues/662)) ([42e1dc9](https://github.com/snapotter-hq/snapotter/commit/42e1dc9799f60c7dde46b9485e5f37a5a29630ae))
* **docs:** localize the docs homepage, nav, and sidebar chrome across 20 languages ([#547](https://github.com/snapotter-hq/snapotter/issues/547)) ([9247947](https://github.com/snapotter-hq/snapotter/commit/92479477042740d7026de9b9bb3c71eeb0668231))
* **download:** ask reverse proxies not to buffer file downloads ([#604](https://github.com/snapotter-hq/snapotter/issues/604)) ([89d7585](https://github.com/snapotter-hq/snapotter/commit/89d75853f45c98d6e9cae661dbda02bc8285009b)), closes [#590](https://github.com/snapotter-hq/snapotter/issues/590)
* **download:** reset the socket when a stream is shorter than Content-Length ([#617](https://github.com/snapotter-hq/snapotter/issues/617)) ([0467e87](https://github.com/snapotter-hq/snapotter/commit/0467e87bfe410b313fa21d9758f8c162b12113a6)), closes [#590](https://github.com/snapotter-hq/snapotter/issues/590) [#590](https://github.com/snapotter-hq/snapotter/issues/590) [#590](https://github.com/snapotter-hq/snapotter/issues/590)
* **editor:** repair rotate/flip/resize, levels/curves, filters, and layer lock ([#597](https://github.com/snapotter-hq/snapotter/issues/597)) ([a6bce68](https://github.com/snapotter-hq/snapotter/commit/a6bce6825ae27890951becf7c858d4626c513714))
* enforce role authority for user management ([#616](https://github.com/snapotter-hq/snapotter/issues/616)) ([1f8a42e](https://github.com/snapotter-hq/snapotter/commit/1f8a42e54822142132270df1aefa768f4626613f))
* enforce settings authority boundaries ([#618](https://github.com/snapotter-hq/snapotter/issues/618)) ([44d8109](https://github.com/snapotter-hq/snapotter/commit/44d8109486aa6efd9383e573f0a36deb4ffda460))
* **erase-object:** crop-based HD inpainting to remove ghosting and blur ([#501](https://github.com/snapotter-hq/snapotter/issues/501)) ([380419d](https://github.com/snapotter-hq/snapotter/commit/380419dd0603693ad542082bd6197e6505f9b1e9)), closes [#491](https://github.com/snapotter-hq/snapotter/issues/491) [hi#res](https://github.com/hi/issues/res) [#141](https://github.com/snapotter-hq/snapotter/issues/141)
* **files:** decode CLI-decoded formats before URL-import preview generation ([#637](https://github.com/snapotter-hq/snapotter/issues/637)) ([841f47f](https://github.com/snapotter-hq/snapotter/commit/841f47f6ca25e79dc56dc1e735afa37f02023f99)), closes [#634](https://github.com/snapotter-hq/snapotter/issues/634)
* **files:** store null instead of 0x0 for undecoded upload dimensions ([#636](https://github.com/snapotter-hq/snapotter/issues/636)) ([511633f](https://github.com/snapotter-hq/snapotter/commit/511633fa1c76f00fe9c4e2a0935a02b5601cae6a)), closes [#635](https://github.com/snapotter-hq/snapotter/issues/635)
* give remove-background job timeouts an actionable failure message ([#518](https://github.com/snapotter-hq/snapotter/issues/518)) ([58121f2](https://github.com/snapotter-hq/snapotter/commit/58121f205f7ae2b34b3d789c80db37c8bf0950bf)), closes [#494](https://github.com/snapotter-hq/snapotter/issues/494)
* **help:** render the help dialog from i18n instead of hardcoded English ([#647](https://github.com/snapotter-hq/snapotter/issues/647)) ([2848dd0](https://github.com/snapotter-hq/snapotter/commit/2848dd0e53d8a7c1e19ee82c1a1f2d485380d356)), closes [#644](https://github.com/snapotter-hq/snapotter/issues/644)
* honor unlimited processing timeout ([#638](https://github.com/snapotter-hq/snapotter/issues/638)) ([025851b](https://github.com/snapotter-hq/snapotter/commit/025851beefaac6f8d12c1dbcdb69276965072ac9))
* **image-tools:** surface Sharp encode failures instead of "Error: Error" ([#534](https://github.com/snapotter-hq/snapotter/issues/534)) ([9cccbc9](https://github.com/snapotter-hq/snapotter/commit/9cccbc957681f8d12547e87be0e335f98e789cb7))
* **image:** decode real iPhone HEIC files instead of rejecting them at validation ([#631](https://github.com/snapotter-hq/snapotter/issues/631)) ([098ed50](https://github.com/snapotter-hq/snapotter/commit/098ed50d0693ba181fae6f934bf427079e1f8e9f)), closes [#622](https://github.com/snapotter-hq/snapotter/issues/622)
* **image:** image-to-pdf presets no longer 404 on 2+ files ([#633](https://github.com/snapotter-hq/snapotter/issues/633)) ([330cf55](https://github.com/snapotter-hq/snapotter/commit/330cf559e00b6e5f1ca8df199cabee4c0fa650d0)), closes [#627](https://github.com/snapotter-hq/snapotter/issues/627)
* **jobs:** classify BullMQ stall (UnrecoverableError) as operational ([#610](https://github.com/snapotter-hq/snapotter/issues/610)) ([e537cb0](https://github.com/snapotter-hq/snapotter/commit/e537cb04013d7bf4fd0fb125fb72f42f5a51917f))
* **jobs:** make timeout messages tool-agnostic and CPU-aware ([#596](https://github.com/snapotter-hq/snapotter/issues/596)) ([37c915c](https://github.com/snapotter-hq/snapotter/commit/37c915c3da6b2183b0befe1521a643bb36edcce6)), closes [#591](https://github.com/snapotter-hq/snapotter/issues/591)
* **landing-i18n:** commit missed landing-seo re-translation (remove.bg comparison) ([b8c3700](https://github.com/snapotter-hq/snapotter/commit/b8c3700c15333b2eb328cab0979f8d943c15d7ab))
* **landing,docs:** use build-time GitHub star count, drop per-page api.github.com fetch ([#560](https://github.com/snapotter-hq/snapotter/issues/560)) ([54073a7](https://github.com/snapotter-hq/snapotter/commit/54073a7c501788dea49f77f6e94bdebb9b8f0f65))
* **landing:** derive conversion-preset copy from real format differences ([#663](https://github.com/snapotter-hq/snapotter/issues/663)) ([a75a22d](https://github.com/snapotter-hq/snapotter/commit/a75a22dd3ff8e675002a13da0ec12d00f91136c8))
* **landing:** keep mixed-case locale casing in emitted URLs ([#562](https://github.com/snapotter-hq/snapotter/issues/562)) ([67f5434](https://github.com/snapotter-hq/snapotter/commit/67f54347b2ca711f40495fa7fcb05e132ea8f3da)), closes [#554](https://github.com/snapotter-hq/snapotter/issues/554)
* **landing:** link English-only tool-detail and self-hosted pages to un-prefixed URLs ([#553](https://github.com/snapotter-hq/snapotter/issues/553)) ([6ecc598](https://github.com/snapotter-hq/snapotter/commit/6ecc598fc4fc8fa6da5db9d9d0c66e53d2f2376d))
* **landing:** open Product dropdown below the navbar bottom line ([#571](https://github.com/snapotter-hq/snapotter/issues/571)) ([e3a01b6](https://github.com/snapotter-hq/snapotter/commit/e3a01b6be6fa077fa00c6c7245be7ba18b6e913c))
* **landing:** rework footer badge/switcher layout ([#514](https://github.com/snapotter-hq/snapotter/issues/514)) ([74e7d23](https://github.com/snapotter-hq/snapotter/commit/74e7d23abad924f04b348f00edf34e67188c4f2e))
* **landing:** stop mangling #hash fragments in localized links ([#516](https://github.com/snapotter-hq/snapotter/issues/516)) ([5bd0522](https://github.com/snapotter-hq/snapotter/commit/5bd052286c5a7920161b21cb791cc122fe3a1616)), closes [#hash](https://github.com/snapotter-hq/snapotter/issues/hash)
* make OCR portable and reliable across AMD64 and ARM64 ([#519](https://github.com/snapotter-hq/snapotter/issues/519)) ([991c981](https://github.com/snapotter-hq/snapotter/commit/991c981529c81ebb719199aba084db4c2d9a7c6f))
* **ocr:** unblock and harden accurate-OCR install ([#552](https://github.com/snapotter-hq/snapotter/issues/552)) ([bda4db3](https://github.com/snapotter-hq/snapotter/commit/bda4db3f358eebd659a1f8b7892112061a82b000))
* **pdf:** flag scanned PDFs in pdf-to-text and serve text as UTF-8 ([#603](https://github.com/snapotter-hq/snapotter/issues/603)) ([7d37f6e](https://github.com/snapotter-hq/snapotter/commit/7d37f6e6f5aa68aa5954182d76bb6ccf1c6b0649)), closes [#589](https://github.com/snapotter-hq/snapotter/issues/589)
* **pdf:** pdf-to-image presets no longer 404 on 2+ files ([#643](https://github.com/snapotter-hq/snapotter/issues/643)) ([a713795](https://github.com/snapotter-hq/snapotter/commit/a7137958a158309ff8128490de96313e517ae554)), closes [#627](https://github.com/snapotter-hq/snapotter/issues/627) [#633](https://github.com/snapotter-hq/snapotter/issues/633) [#632](https://github.com/snapotter-hq/snapotter/issues/632)
* **pdf:** restore downloads on PDF conversion preset pages ([#629](https://github.com/snapotter-hq/snapotter/issues/629)) ([e0a7aec](https://github.com/snapotter-hq/snapotter/commit/e0a7aecde8f194b6375747a5a5f8ea4c57053813)), closes [#623](https://github.com/snapotter-hq/snapotter/issues/623)
* **pdf:** stop page tools failing on short and encrypted PDFs ([#594](https://github.com/snapotter-hq/snapotter/issues/594)) ([73df107](https://github.com/snapotter-hq/snapotter/commit/73df1077580904c37d159dafe51a294b250bb074))
* preserve colored blocks in PDF-to-Word ([#500](https://github.com/snapotter-hq/snapotter/issues/500)) ([e7cfc00](https://github.com/snapotter-hq/snapotter/commit/e7cfc00fe1d294256a0a4663797b8a8cdf58d8c0))
* **readme:** self-host the star history chart ([#625](https://github.com/snapotter-hq/snapotter/issues/625)) ([90483c9](https://github.com/snapotter-hq/snapotter/commit/90483c944a4d4c912d3c5e2c09b2ee61bed3d150))
* release QA hardening across processing, media, security, and CI gates ([#649](https://github.com/snapotter-hq/snapotter/issues/649)) ([d10d0f5](https://github.com/snapotter-hq/snapotter/commit/d10d0f544f2a093d2adb13cbd69d54a31c0cbf7a))
* **release:** resolve the release by id, and make the vulnerability gate cover HIGH ([#661](https://github.com/snapotter-hq/snapotter/issues/661)) ([935861b](https://github.com/snapotter-hq/snapotter/commit/935861bced95cd778ea77d5a3741ff1da1a2ffed)), closes [#649](https://github.com/snapotter-hq/snapotter/issues/649)
* **security:** bump Pillow to 12.3.0, fixes 5 disclosed CVEs ([#517](https://github.com/snapotter-hq/snapotter/issues/517)) ([30d36d7](https://github.com/snapotter-hq/snapotter/commit/30d36d7949817751b869916775138dc4fa7a34d2))
* **security:** close the gaps a full 2.0 re-audit left open ([#620](https://github.com/snapotter-hq/snapotter/issues/620)) ([079fcd2](https://github.com/snapotter-hq/snapotter/commit/079fcd2631216147db4b68acbcaca58ac4f93fb4))
* **seo:** emit clean docs URLs and de-index the demo ([#598](https://github.com/snapotter-hq/snapotter/issues/598)) ([e6718ad](https://github.com/snapotter-hq/snapotter/commit/e6718ada719e8931f643e1184aee354d4698c1da))
* **settings:** let admins relax the minimum password length to 1 ([#543](https://github.com/snapotter-hq/snapotter/issues/543)) ([846044a](https://github.com/snapotter-hq/snapotter/commit/846044a463f18f490f7752b0cb6b4b225b107367)), closes [#136](https://github.com/snapotter-hq/snapotter/issues/136)
* **sign-pdf:** surface PDF load failures instead of a blank canvas ([#545](https://github.com/snapotter-hq/snapotter/issues/545)) ([1f4878a](https://github.com/snapotter-hq/snapotter/commit/1f4878ac4df417331a8cff9b8d5ab4da2edc788a))
* **telemetry:** classify environmental database errors as operational ([#540](https://github.com/snapotter-hq/snapotter/issues/540)) ([631d82e](https://github.com/snapotter-hq/snapotter/commit/631d82eaae36a15d401c7408182d52a0754b1e36))
* **telemetry:** data-quality pass (opt-in noise, onboarding split, file_count, OIDC) ([#614](https://github.com/snapotter-hq/snapotter/issues/614)) ([b20bca3](https://github.com/snapotter-hq/snapotter/commit/b20bca3c3c4ef3a63aca6ba420986d49bbeb905b)), closes [hi#level](https://github.com/hi/issues/level)
* **telemetry:** fingerprint stackless uncaught errors so they stop collapsing ([#611](https://github.com/snapotter-hq/snapotter/issues/611)) ([82f5708](https://github.com/snapotter-hq/snapotter/commit/82f5708193984e863b95319e0e0ec8be47e46075))
* **telemetry:** sharpen Sentry signal for v2.1.0 residual defects ([#498](https://github.com/snapotter-hq/snapotter/issues/498)) ([b457596](https://github.com/snapotter-hq/snapotter/commit/b4575966495c1a022154149832aedcd727d411e2))
* **telemetry:** surface AI sidecar and DOMException failure reasons in Sentry ([#612](https://github.com/snapotter-hq/snapotter/issues/612)) ([6a0768b](https://github.com/snapotter-hq/snapotter/commit/6a0768b39da648d3b62b39f2370b97f9ce876da0)), closes [#535](https://github.com/snapotter-hq/snapotter/issues/535)
* **tools:** classify expected input and timeout errors, not bugs ([#539](https://github.com/snapotter-hq/snapotter/issues/539)) ([39b89b9](https://github.com/snapotter-hq/snapotter/commit/39b89b9fbd4f67012b55ecdef35ddf369a3fb874))
* **video:** write faststart mp4/mov output from stabilize-video ([#593](https://github.com/snapotter-hq/snapotter/issues/593)) ([df92f7e](https://github.com/snapotter-hq/snapotter/commit/df92f7ee42b22b5b6cb3434a6b972851388c67ad)), closes [#588](https://github.com/snapotter-hq/snapotter/issues/588)
* **web:** use h-dvh for app shells so mobile tool controls stay reachable ([#559](https://github.com/snapotter-hq/snapotter/issues/559)) ([3f7214b](https://github.com/snapotter-hq/snapotter/commit/3f7214bac22fe392eb7d179752e9c4583e4dbe0e))

### Features

* add landing testimonial wall, unblock the onboarding survey ([#639](https://github.com/snapotter-hq/snapotter/issues/639)) ([5cc0a85](https://github.com/snapotter-hq/snapotter/commit/5cc0a850c68e0af7a427b8442c24c55f6157416e))
* **analytics:** instance census, full capture, richer error context ([#511](https://github.com/snapotter-hq/snapotter/issues/511)) ([e1b8c24](https://github.com/snapotter-hq/snapotter/commit/e1b8c24e5d78153a62629972932a65cd1491ee77))
* clearer, disambiguated tool names across all surfaces ([#520](https://github.com/snapotter-hq/snapotter/issues/520)) ([f858c4c](https://github.com/snapotter-hq/snapotter/commit/f858c4cea00ae7165718509c8a78881fe6fc24fc))
* **docs-i18n:** translate all documentation into 20 languages ([4963ab3](https://github.com/snapotter-hq/snapotter/commit/4963ab3bbd36fe86b856a3be791f192af4582142))
* **erase-object:** add freeform lasso selection mode ([#503](https://github.com/snapotter-hq/snapotter/issues/503)) ([601557e](https://github.com/snapotter-hq/snapotter/commit/601557edaed32f331b5271334923f1c010098d91))
* **erase-object:** optional high-quality diffusion inpainting bundle ([#566](https://github.com/snapotter-hq/snapotter/issues/566)) ([1bac663](https://github.com/snapotter-hq/snapotter/commit/1bac663a2e76c98313a7a56c97231c2bbc1b36f2)), closes [hi#quality](https://github.com/hi/issues/quality) [#141](https://github.com/snapotter-hq/snapotter/issues/141)
* **feedback:** gate onboarding survey on first processing, add prompt lifecycle events ([#615](https://github.com/snapotter-hq/snapotter/issues/615)) ([129e42b](https://github.com/snapotter-hq/snapotter/commit/129e42b95cd9c078eebcb3c52be10e8661dd7903))
* **files:** add save-as-new vs overwrite choice for library file edits ([#564](https://github.com/snapotter-hq/snapotter/issues/564)) ([a23158d](https://github.com/snapotter-hq/snapotter/commit/a23158d968c32e8cdf319225d73bf51120f20cbf)), closes [#495](https://github.com/snapotter-hq/snapotter/issues/495)
* **i18n:** 21-language pipeline, landing/docs/API wiring, landing+API translations ([00b651c](https://github.com/snapotter-hq/snapotter/commit/00b651c9f851a8754cac755f6929ba2eeac8602b))
* **image:** add rounded-square and squircle crop tool ([#602](https://github.com/snapotter-hq/snapotter/issues/602)) ([e7ffb37](https://github.com/snapotter-hq/snapotter/commit/e7ffb37e981c553cf17db88155ac12667f104062)), closes [#601](https://github.com/snapotter-hq/snapotter/issues/601)
* **landing-i18n:** recall a returning visitor's chosen language ([1bc9fe9](https://github.com/snapotter-hq/snapotter/commit/1bc9fe93ec4ca82295e77f2c8068bc700ef94367))
* **landing:** add a live system status indicator to the footer ([#641](https://github.com/snapotter-hq/snapotter/issues/641)) ([d690a6e](https://github.com/snapotter-hq/snapotter/commit/d690a6e26d82f9783cac154faa11f19481902a0e))
* **landing:** add Twelve Tools and Wired Business footer badges ([#512](https://github.com/snapotter-hq/snapotter/issues/512)) ([ce54d06](https://github.com/snapotter-hq/snapotter/commit/ce54d06e797c8f3fe25ed8c00ef42047ddf63882))
* **landing:** move language switcher from navbar to footer ([#513](https://github.com/snapotter-hq/snapotter/issues/513)) ([2dee9ea](https://github.com/snapotter-hq/snapotter/commit/2dee9ead0b1c84c02831c5e92082cd4598b7b8f5))
* **library:** wire save-mode into the five custom-client tool submitters ([#577](https://github.com/snapotter-hq/snapotter/issues/577)) ([1113c76](https://github.com/snapotter-hq/snapotter/commit/1113c761ead67664e4a7120b8bdf1b8e9da52c43))
* **onboarding:** collect install method and friction area in the usage survey ([#499](https://github.com/snapotter-hq/snapotter/issues/499)) ([430b87e](https://github.com/snapotter-hq/snapotter/commit/430b87eda0078c9afe31aa49da1f2293c928a147))
* **resize:** add aspect-ratio proportion presets ([#530](https://github.com/snapotter-hq/snapotter/issues/530)) ([d88999e](https://github.com/snapotter-hq/snapotter/commit/d88999e7a91d65adb37ea4cb3f9f7fe5650ad6c5))
* **search:** start typing anywhere to fill the search box ([#644](https://github.com/snapotter-hq/snapotter/issues/644)) ([0058fc6](https://github.com/snapotter-hq/snapotter/commit/0058fc610f4b88ddf0660889b2ea78bf33a527d1))
* **telemetry:** add a safe input_format tag to worker error reports ([#541](https://github.com/snapotter-hq/snapotter/issues/541)) ([281b4a0](https://github.com/snapotter-hq/snapotter/commit/281b4a06e39756a3a2b981e93d033454eb494c8c))
* **telemetry:** Sentry + PostHog quality pass ([#546](https://github.com/snapotter-hq/snapotter/issues/546)) ([8625143](https://github.com/snapotter-hq/snapotter/commit/86251434b5858c63049c50dda44acb8e281a4cdf))
* **tools:** remove background from animated GIFs (WebP, APNG) ([#502](https://github.com/snapotter-hq/snapotter/issues/502)) ([cb5db59](https://github.com/snapotter-hq/snapotter/commit/cb5db59f77e6b77fd9a575996b8514d6760aaf5b))
2026-07-29 09:50:41 +00:00
SnapOtter 0a02f3b8e4 docs: add release notes for v2.2.0 2026-07-29 17:49:18 +08:00
SnapOtterandGitHub 935861bced fix(release): resolve the release by id, and make the vulnerability gate cover HIGH (#661)
Two release-pipeline defects found while pre-flighting 2.2.0, plus the image
hardening that the second one exposed.

The release job would have died immediately after pushing the v2.2.0 tag.
draftRelease was turned on in #649 and never executed, and GitHub's
/releases/tags/{tag} endpoint does not return draft releases, so all nine tag
lookups in release.yml would have 404'd against the draft semantic-release had
just created. Verified against this repo with a throwaway draft: the tag
endpoint 404s while gh release view reads it and /releases/{id} returns the same
REST shape. Every site now resolves the numeric id first, so existing jq
expressions are untouched.

The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy
steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the
remainder but defaults to CRITICAL with neither call site passing --severity. An
unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while
the summary read clean.

Rather than document 79 findings, the image lost what it did not need:
libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin
was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the
decoder every .heic upload actually reached), and xvfb, wget and openssh-client
are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each
now carrying a rationale verified against the running container.

curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no
fix was available would have been false. It is recorded as a declined fix.

Verified on both architectures: gate exits 0, the source-built libde265 is the
one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium
all still work after the purge.
2026-07-28 22:49:56 +08:00
SnapOtterandGitHub a75a22dd3f fix(landing): derive conversion-preset copy from real format differences (#663)
The 83 X-to-Y converter pages were generated from one template with the
format names substituted in. Three of the four features and two of the
three FAQs were byte-identical across all 82 non-override presets, every
page came out at exactly 144 words, and pages within a family sat at
roughly 61% similarity with identical shingle counts. That is the
mass-produced fingerprint, and the same shape that got the docs tool
pages deduped in #662. All 83 are indexed today, so this is prevention
rather than repair.

Copy is now derived from what actually differs between the two formats:
alpha, animation, colour depth, lossy re-encode behaviour, container
reach, and where each format is genuinely accepted. png-to-jpg explains
that transparency gets flattened; gif-to-jpg warns you keep frame one
only; psd-to-svg says to export Photoshop's real vector layers instead of
tracing them.

Mean similarity across all 3,403 pairs drops from 34.8% to 6.7%, and the
uniform 144-word length is gone (206-256 words, varying). Within-family
max is roughly flat: on the built pages, image goes 62.8% to 66.1%, video
61.3% to 66.4%, audio 60.7% to 60.6%. Generated copy has a similarity
floor, and beating it inside a family would take hand-written pages. What
has gone is the identical shingle count per family, which was the
strongest tell.

Also drops the one-off eps-to-svg override: all five vectorize presets
now get honest tracing copy, not just that one.

Verified: landing builds 798 pages, zero verbatim repeats between a
page's blurb and its own features or FAQs, biome clean.
2026-07-28 17:08:53 +08:00
SnapOtterandGitHub 42e1dc9799 fix(docs): keep the translated locale trees out of the search index (#662)
Search Console flagged four reasons on 2026-07-28: soft 404, both
duplicate-canonical variants, and noindex. All four sat on
docs.snapotter.com; the landing site was clean.

The cause was boilerplate dominance rather than a broken tag.
/tools/video/crop-video carries 1.3 KB of unique body against 3.5 KB of
identical chrome (nav, sidebar, 21-language switcher), so unrelated tool
docs measured 52-60% full-page similarity. Across 20 locales that was
3,640 of 3,822 submitted URLs. Google read the lot as one duplicate
cluster and began electing arbitrary representatives: /changelog became
the canonical for /tools/image/favicon, and /uk/guide/getting-started for
/nl/tools/image/resize. English tool docs indexed 2 of 10 and localized 4
of 10, while the landing page for those same five tools indexed 5 of 5.

Translated pages now emit `noindex, follow` with a self-canonical, and
sitemap.transformItems drops them from the sitemap. Self-canonical rather
than pointing at English, since noindex paired with a cross-canonical
sends two conflicting instructions. hreflang is removed outright: the
annotation only means something between pages that can all be indexed.

Readers see no change. The language switcher and every in-page link
behave exactly as before.

Verified against a real build: sitemap 3,822 to 182 URLs with zero
translated entries, all 3,640 translated files carrying the noindex and
no English file doing so, docs e2e 100 passed.
2026-07-28 17:08:32 +08:00
SnapOtterandGitHub 5f21588f6c chore: prepare the 2.2.0 release (#660)
Bumps every version surface to 2.2.0, fixes a latent version-coupling bug in the
OCR runtime tests, and stops an absent GPU runner from silently stalling a
release.

Version surfaces: scripts/sync-version.sh covers the 11 workspaces, APP_VERSION,
and the docs release commands across all locales. Root package.json plus the
three surfaces the script never reaches are done by hand: the DOCKERHUB.md banner
and tag table, the docker-tags.md pinning table in 21 locales, and the example
runtimeVersion in tools/image/ocr.md in 21 locales. The release-notes archive step
is deliberately not pre-run, so the notes text stays editable until the release.

Latent bug: runtime-state rejects any runtime whose compatibility.snapotterVersion
is not exactly APP_VERSION, and five fixtures pinned the literal 2.1.0. Since
semantic-release rewrites APP_VERSION on every release, the first PR after any
bump would have gone red for a reason nobody would trace to the release. The
fixtures now derive from APP_VERSION.

GPU runner: sign-ocr-index needs verify-ocr-nvidia on self-hosted hardware, and
the gated manifest job needs ai-bundles, so a missing runner queued instead of
failing and produced no image tags. preflight-gpu-runner claims the same labels
with no dependencies, so it is scheduled first and validates the GPU before the
90-minute build. An API preflight is impossible because listing self-hosted
runners needs Administration:read, which GITHUB_TOKEN cannot hold, so RELEASE.md
carries the maintainer-side check.
2026-07-27 22:09:31 +08:00
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00
SnapOtterandGitHub bc32f86a07 test: correct the SYNC_WAIT_MS comment and guard six 202 branches (#656)
The comment added in #652 was wrong. It said SYNC_WAIT_MS=0 drives every tool through its 202 path; it does the opposite. BullMQ arms its timer under `if (ttl)`, so 0 is falsy, waitUntilFinished waits forever and every route answers 200, matching the repo-wide convention that 0 means unlimited. A small positive value such as 1 is what forces the async path.

That error mattered: the local validation claimed for #652 exercised the synchronous path throughout and never reached settleAsyncFallback. Redone with SYNC_WAIT_MS=1, it surfaced six specs whose status gate accepts 202 but whose else branch then demands an error body a 202 never carries, a latent flake on a slow runner.

Each now settles the job first. Under a forced 1ms window the 'expected undefined to be defined' failures drop from 55 to 0, and all 1183 tests still pass on the normal 30s window. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.

Specs asserting a bare 200 without listing 202 are deliberately untouched: they own the synchronous contract.
2026-07-27 14:20:29 +08:00
SnapOtterandGitHub f1ec3beaf7 test: settle 202 jobs instead of returning without asserting (#652)
A 202 means the sync window expired while the job was still running. Tests treated it as a terminal pass: `if (isAsyncFallback(res)) return;` checked the envelope and returned, asserting nothing about the outcome and leaving the job running into the next test, which is the leak cancelAcceptedJobAndWait exists to prevent.

Because the window only expires under load, coverage tracked runner load. On CI 44 tests took this path and verified nothing; the same tests on a dev machine asserted in full (one measured 6.4s locally against 31s on CI).

settleAsyncFallback waits for a terminal state and asserts the job finished, and that a failure carries a message rather than being a crash. A clean failure stays valid, since the exotic-format fixtures are meant to be rejected. All 82 call sites moved over.

per-fork-env no longer floors SYNC_WAIT_MS, so forcing it to 0 drives every request through its 202 path. 570 tests were validated that way and matched their normal-window results exactly.

The 29-34s band dropped from 44 tests (23.4% of test time) to 6 (3.0%). Total test time rose 5.8% and CI wall went 12.8 to 13.1 min: the forks were doing real work during that wait, so this buys determinism, not speed. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
2026-07-27 12:16:48 +08:00
SnapOtterandGitHub d9978525fe test: split the three oversized format-matrix specs (#651)
Vitest shards by file and runs a file's tests serially in one fork, so a single spec set the floor for the whole Integration job no matter how many shards or forks it got. Cost-aware sharding (#650) balanced the shards but could not get under that floor.

Split the three specs that exceeded it: format-matrix-comprehensive (1365s) into 4 by describe, format-matrix (1130s) into 4 with Cross-format matrix striped over FORMAT_SAMPLES, and format-matrix-generated (779s) into 3 striped over TOOLS. Largest spec is now 370s. Each preamble moved verbatim into a sibling .shared.ts exposing setupMatrixApp().

Integration shards went from 20m59s/17m55s/16m33s/9m19s to 11m44s/12m31s/10m37s/11m1s.

Coverage checked, not assumed: the set of test names collected by vitest list is byte-identical across the split, 2151 before and 2151 after. Per-shard totals matched the baseline exactly at 9903 tests, 9435 passed, 468 skipped.
2026-07-27 00:36:51 +08:00
SnapOtterandGitHub d91abc8a23 test: shard vitest by measured cost instead of path hash (#650)
Vitest's BaseSequencer sorts specs by SHA1 of their path and slices an equal number of files per shard, which is blind to how long each one takes. That dropped the four costliest generated matrix specs into a single shard: 24m47s against shard 1's 3m47s.

Partition by measured cost instead, greedy longest-processing-time-first. CI wall goes 25 min to 21.3 min. The remaining ceiling is that `format-matrix-comprehensive` and `format-matrix` are each one indivisible file, and tests within a file run sequentially in a single fork.

Coverage is unchanged by construction. The partition is total and disjoint, guarded over the real spec list for shard counts 1 through 8. Per-shard totals on the PR run matched the baseline exactly: 297 files, 9903 tests, 9435 passed, 468 skipped.
2026-07-27 00:06:04 +08:00
SnapOtterandGitHub ada5727114 ci: take Build off the integration shard critical path (#648)
Build listed six test jobs in `needs` but only reads `needs.changes.outputs.code`, in its `if`. The rest were ordering, not data.

That queued a 55s job behind the slowest integration shard, so it added its full runtime to the tail of every green run. Build and the test jobs are independent required checks, so a green Build still can't merge past a red shard.
2026-07-26 23:12:21 +08:00
SnapOtterandGitHub 2848dd0e53 fix(help): render the help dialog from i18n instead of hardcoded English (#647)
The help dialog carried its 13 shortcut labels, its getting-started
paragraph and its version line as hardcoded English, while fully
translated strings for exactly those labels sat unused in all 21 locale
files. Every non-English user read English there. The translations did
not need writing, only reading: t.help.keyboardShortcuts already had
focusSearchBar, goToTools, processFile and the rest, in every locale.

Labels now index into t.help.keyboardShortcuts by key rather than
carrying text. Getting-started reads t.help.gettingStarted.description,
which drops the inline Kbd chip the hardcoded copy had, matching what all
21 locales already say. The version line goes through
t.help.versionLabel.

Also adds the type-to-search row that #644 left out, keyed
help.keyboardShortcuts.typeToSearch, translated into all 21 locales, and
regenerates the two darwin help-dialog baselines for the extra row.

Nothing caught the original bug because the i18n context defaults to en,
so asserting on English text passes whether or not the component reads
i18n at all. The new test mocks the context with sentinel values instead:
putting the hardcoded labels back fails 15 of its 19 cases.

Verified: 19 new unit tests, full unit suite 7576 passed, help-dialog
visual 3/3 against regenerated baselines, help accessibility e2e 7/7,
typecheck and lint clean, all 18 CI checks green.
2026-07-26 10:28:02 +08:00
SnapOtterandGitHub 2d8b57c57f fix(api): gate every tool endpoint and stop ZIP streams failing quietly (#646)
Three defects from #645, all of which let the server report something
that was not true.

Tool access was enforced per route, so it drifted. createToolRoute calls
requireToolAccess and the factory tools were fine, but all 45
hand-written routes had to remember the same call and none of them did.
A role without tools:use could run image-to-pdf, svg-to-raster,
erase-object, favicon, qr-generate, upscale, sign-pdf and the rest. The
issue described this as affecting two routes; it was every one of them.

The check now lives in a single preHandler keyed off the tool the router
matched, so it covers sub-paths (/batch, /info, /preview, /analyze,
/inspect) and any route added later without that route opting in. Ids no
tool claims stay unresolved, which keeps an unknown or misfiled tool a
404 rather than telling an unauthorized caller which ids exist.

Resolution reads request.routeOptions.url, the pattern the app itself
registered, rather than parsing request.url a second time. find-my-way
decodes before matching, so an independent parse disagrees with the
router and the router wins: `/api/v1/tools/image/%66avicon` ran favicon
while the gate saw no tool at all. Absolute-form request targets slipped
it the same way. Taking the router's own answer removes the disagreement.

A ZIP stream that failed after the 200 headers were out called
reply.raw.end(). On a chunked response that is indistinguishable from
success, so a client kept an archive with no central directory believing
it whole. Worse, a source stream that errored had no listener: the
request hung until it timed out and the error surfaced as unhandled. A
poisoned-storage probe reproduced both. The socket is destroyed instead,
and every source stream is listened to. svg-to-raster additionally ran
its append loop past the hijack with no try/catch, where a throw leaves
Fastify logging and walking away with the socket neither ended nor
destroyed. pdf-to-image is fixed alongside the other two: it shipped in
#643 with the destroy half but not the listener, so it hung the same way.

A zero-byte upload was dropped during parsing. The client pairs results
with its own file list by index, so every later result shifted onto the
wrong file: one document's output was presented as another's, under
another's name, while the file that actually converted was marked "not
found in batch results". Empty parts now keep their slot and fail in
place with a reason.

Two tests in adversarial-extended.test.ts asserted the old zero-byte
behavior, including a comment that batch "silently skips zero-byte
parts". They now pin the replacement: still rejected, nothing processed,
but the caller is told which files were empty instead of being told it
sent none.

A guard walks the whole catalog and fails if any of the 241 tools answers
anything but 403 for a role without tools:use, so a tool cannot escape
the gate by being registered in a shape nobody thought to sample.

Fixes #645
2026-07-26 09:57:44 +08:00
SnapOtterandGitHub 0058fc610f feat(search): start typing anywhere to fill the search box (#644)
Type a printable character on the landing homepage or the app's home
dashboard and it lands in the search box, provided the box is on screen
and nothing else holds focus. Mod+K keeps working unchanged.

The parts that are easy to get wrong live in
packages/shared/src/search/type-to-search.ts so the two surfaces cannot
drift. isTypeToSearchKey decides whether a keystroke is text.
isSearchBoxTypeable decides whether the box is reachable, via one
elementFromPoint hit test at its center, which folds off-screen,
covered-by-a-modal and hidden into a single check that leans on no one's
aria markup. It fails closed where there is no layout engine, so jsdom
tests that mount the search bar do not blow up on it.

Modifier handling reads getModifierState("AltGraph") rather than
inferring AltGr from ctrl+alt. That inference reads correctly on Windows
and is backwards on macOS, where Option alone types accented characters
and ctrl+alt is a shortcut prefix, VoiceOver's included.

Focus is claimed before the keystroke is committed. Browsers silently
refuse focus inside inert or visibility:hidden subtrees, and without the
check an entire query drains into a box the user cannot see.

Scope comes from where the hook is mounted rather than a route check that
could rot, so tool pages, the editor, Files and Automate get nothing. No
new i18n strings, and no new analytics event, since
ANALYTICS_EVENTS.SEARCH already fires off the same state change.

Verified: 44 new unit tests, full unit suite 7557 passed, landing
homepage 24/24, home-page 19/19, gui-keyboard 41/41, typecheck and lint
clean, all 18 CI checks green.
2026-07-26 08:27:17 +08:00
SnapOtterandGitHub a7137958a1 fix(pdf): pdf-to-image presets no longer 404 on 2+ files (#643)
Upload two PDFs to pdf-to-jpg and it answered `Tool "pdf-to-jpg" not
found`. pdf-to-jpg, pdf-to-png and pdf-to-tiff share
registerPdfToImageRoute, which registered a single-file endpoint and
nothing else, so the shared preset settings component's 2+-file
submission fell through to the generic `:section/:toolId/batch` route,
whose registry lookup misses every tool outside
createToolRoute/registerToolProcessFn.

Mirror of #627, different fix. image-to-pdf is many-to-one, so #633 sent
every file in one request. This direction is one-to-many: separate PDFs
want separate conversions, which is what /batch is for. The route now
serves its own /batch, the shape svg-to-raster already uses, and the
literal path beats the generic parametric one.

One PDF fans out to many page images, so a per-file result is a ZIP, same
as the single-file route. A batch returns a ZIP of per-document ZIPs in
upload order, keyed by X-File-Results so each result pairs with the file
it came from. A document that is unreadable, locked, empty, short of the
requested page range, or carrying no pages at all fails alone; 422 with a
reason per file when none survive.

That literal path also shadows the generic route's requireToolAccess
call, which would have turned a 403 into a converted ZIP for roles
without tools:use. All four endpoints in this file now gate.

Four ways the batch path could have reported something untrue are closed
with it: a storage fault blamed on the document (statusCode-carrying
errors now reach the error handler, the rest are logged before being
reduced to a generic message), per-file reasons stranded in a field
parseApiError never reads, a zero-byte upload dropped so that later
results landed on the wrong file, and a mid-stream failure ended cleanly
enough to pass for success (the socket is destroyed instead).

Page rendering and ZIP assembly are shared helpers now, createUniqueNamer
moves to lib/filename.ts next to its two existing copies, and
tool-route-drift fails if any batch-dispatched preset loses its /batch
route. Follow-up for the same defects in the sibling custom routes: #645.

Fixes #632
2026-07-26 01:35:42 +08:00
SnapOtterandGitHub d690a6e26d feat(landing): add a live system status indicator to the footer (#641)
Adds /api/status to the landing Pages worker, HEAD-probing demo.snapotter.com and
docs.snapotter.com with a 2 second per-attempt deadline and one retry. snapotter.com is
not probed; the worker answering the request is the proof it is up.

The footer badge ships grey in the static HTML and only upgrades once the route answers.
A rejected fetch, a non-ok response, an unparseable body, and an unrecognized verdict all
leave it grey, so it never claims green on its own.

Color lives in the dot, never the label: `--color-success` scores 4.498:1 against the
footer's `--color-background-alt`, just under AA.

Four labels across 21 locales.
2026-07-25 20:52:42 +08:00
SnapOtterandGitHub d9a8ae7b7e test(web): stop the onboarding Escape test racing the passive effect (#640)
Unit Tests went red on main after #639 with one failure: the Escape test saw a
fully rendered dialog but zero apiPut calls, so the handler never ran. Escape is
the only control in that file wired through a document listener in a passive
effect; every other control is an onClick prop attached at commit, which is why
the sibling close-button test passed in the same run.

Could not reproduce it: 50 isolated and whole-file runs stayed green, and a probe
counting document keydown registrations shows the listener is already attached
when findByText resolves locally, so the window only opens under CI contention.
Rather than bet on an unconfirmed mechanism, settle effects and retry the
dispatch until the dismissal lands. handleDismiss guards on `busy` and the
settings write is idempotent, so repeats are harmless.

Still non-vacuous: pointing the handler at a key that never fires fails the test
in about a second. 30 consecutive whole-file runs clean, full unit suite 7,474
passing.
2026-07-25 19:50:53 +08:00
SnapOtterandGitHub 5cc0a850c6 feat: add landing testimonial wall, unblock the onboarding survey (#639)
Testimonial wall: new landing section between Feature Highlights and Pricing.
Two rows drifting in opposite directions, CSS-only to keep the zero-runtime
rule. 22 quotes, all verbatim and traceable to a public URL or a
feedback_submitted event. In-app quotes ship unattributed because the feedback
dialog only ever promised "You can contact me about this feedback". Marquee
traps documented in the CSS: a track gap also sits between the last original
and the first clone, so the -50% translate jumped half a gap per loop; and
under dir="rtl" the flex track drifted itself off-screen while "@amn-96"
bidi-reordered to "amn-96@".

Landing stats: DOCKER_FALLBACK read 104,000 against a real 233,057, but the
stale constant was the symptom. Both fetchers swallowed failures in a bare
catch, so a degraded build never announced itself. That warning then exposed
the real bug: getStarCount runs from Navbar and TrustSignals on all 798 pages,
firing ~800 unauthenticated GitHub calls per build and 403ing partway through,
so early pages carried the live count and later pages the fallback. Both
fetchers now memoize the promise.

Onboarding survey: the shipped gate has no activity condition, so it fires on
first admin login; 1,105 of 1,287 surveyed instances never processed a file.
The opaque fixed inset-0 aria-modal with a focus trap and no Escape becomes a
corner card at 12% of the screen, Escape closes, and the optional questions
stay collapsed until the one required answer. Its title was an h1, which
RouteAnnouncer focuses and announces on every route change, so navigating
anywhere announced the survey instead of the page. Now an h2.
2026-07-25 18:45:38 +08:00
SnapOtterandGitHub 025851beef fix: honor unlimited processing timeout (#638)
* fix(web): recover stalled job progress streams

* fix(ai): honor unlimited processing timeout

* fix(web): keep retrying stalled progress streams
2026-07-25 11:36:02 +08:00
SnapOtterandGitHub 841f47f6ca fix(files): decode CLI-decoded formats before URL-import preview generation (#637)
fetch-urls.ts called sharp(buffer).webp() directly on the raw, undecoded buffer for CLI-decoded formats (HEIC, RAW, PSD, ...), which throws and gets silently swallowed, so URL-imported files in those formats came back with no preview and 0x0 dimensions. Decodes once before both preview generation and dimension lookup, mirroring the pattern already used elsewhere in the codebase.

Fixes #634
2026-07-25 10:46:50 +08:00
SnapOtterandGitHub 511633fa1c fix(files): store null instead of 0x0 for undecoded upload dimensions (#636)
validateImageBuffer() intentionally reports {width: 0, height: 0} for every CLI_DECODED_FORMATS member. The file library's upload and save-result endpoints treated that 0 as a real measurement and wrote it into the DB. Adds a measuredDimensions() helper that treats non-positive width/height as unmeasured and stores null instead.

Fixes #635
2026-07-25 10:46:11 +08:00
SnapOtterandGitHub 098ed50d06 fix(image): decode real iPhone HEIC files instead of rejecting them at validation (#631)
validateImageBuffer() never listed heif in CLI_DECODED_FORMATS, so real iPhone HEIC uploads hit Sharp's own metadata probe (its bundled libheif only supports AV1/AVIF) and got rejected before reaching the working heif-convert/heif-dec decode path already wired up downstream. Adds heif to that set, same as raw/psd/tga/bmp/etc.

Also fixes the same gap on erase-object's mask input, which validates through the same function but had no matching decode step, so a HEIC mask reached an unguarded sharp() call and came back as a misclassified server error instead of a clean 422.

Fixes #622
2026-07-25 10:45:12 +08:00
SnapOtterandGitHub 330cf559e0 fix(image): image-to-pdf presets no longer 404 on 2+ files (#633)
jpg-to-pdf and its six image-to-pdf-group siblings share the base tool's
registerImageToPdfRoute, which never registers into the toolRegistry the
generic /batch endpoint reads from. The shared conversion-preset settings
component routed any 2+-file submission to /batch regardless of tool, so
these presets 404'd with `Tool "<id>" not found` past the first file, while
the base image-to-pdf tool stayed unaffected because it bypasses that
dispatch entirely with its own settings component.

MULTI_FILE_TOOLS now includes every image-to-pdf-group preset, derived from
BASE_CONFIG instead of hardcoded, and the preset settings component checks
that set before choosing batch vs. a single combined request.

Fixes #627
2026-07-25 09:18:18 +08:00
EuanandGitHub e0a7aecde8 fix(pdf): restore downloads on PDF conversion preset pages (#629)
Adds downloadUrl/originalSize/processedSize to the pdf-to-image route's synchronous response so PDF conversion presets (pdf-to-png, pdf-to-jpg, pdf-to-tiff) satisfy the standard tool-result contract and show their download action again.

Fixes #623

Co-authored-by: EuanTop <euan@mail.bnu.edu.cn>
2026-07-24 18:53:19 +08:00
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00
SnapOtterandGitHub eee6f0d470 chore(readme): drop redundant repo name from star history chart (#626)
Follow-up to #625. The chart renders under the repo heading in the README, so the snapotter-hq/SnapOtter title baked into the SVG was redundant. Keep only the Star history heading; repo name stays in the aria-label for screen readers.
2026-07-24 07:19:29 +00:00
SnapOtterandGitHub 90483c944a fix(readme): self-host the star history chart (#625)
GitHub restricted the stargazers API to a repo's own admins/collaborators (June 2026), so star-history.com's shared token pool 503s and the README chart went blank. Generate the chart from our own stargazer timeline instead (default GITHUB_TOKEN has access), publish it to the star-history branch, and embed it by raw URL. Weekly workflow keeps it fresh.
2026-07-24 06:44:28 +00:00
SnapOtterandGitHub 44f5aea326 fix(ci): repair the chronically-failing nightly workflow (#624)
The scheduled Nightly had been red for over a week across nearly every job. This
root-causes and fixes each one. All were pre-existing: missing CI provisioning,
specs that drifted as the app grew, a job too heavy for its timeout, and a fuzz
that was never configured for file-upload endpoints. None came from the recent
security merge.

- Coverage + Docker Container E2E: install tesseract and its language packs so
  the built-in Fast OCR tests stop throwing spawn ENOENT; gate two repo-file and
  release-workflow tests that cannot run inside the slimmed container image.
- E2E (Full, Serial, Cross-Browser, Device Matrix): refresh specs that drifted
  behind the app (tool renames, the now admin-only Tools tab, dropped About copy,
  locator collisions scoped to the right region). One real product fix rode
  along: /config/auth was refetched six times per tool-page load, so cache it
  behind a single shared fetch, dropping the tool page from 13 to 8 API calls.
- Extended Matrix + Fuzz: shard the integration suite four ways so the full
  format x tool matrix plus property fuzz fits its budget instead of overrunning
  the 90-minute ceiling every night.
- Schemathesis: exclude the tools with bespoke handlers that process
  synchronously in-request (they hang the fuzz on adversarial input) and suppress
  Hypothesis's data-generation health checks, which fire because file-upload
  endpoints reject the fuzzer's random bytes. not_a_server_error still runs on
  every generated case (5000+ per run).
- Stabilize two long-tail flakes: raise the avif matrix per-test cap from 240s to
  600s, and assert toHaveCount(0) on the deleted user row so a transient success
  toast no longer trips a strict-mode violation.

Verified end to end: the full Nightly workflow is green on this branch (all 14
jobs), and PR CI is green.
2026-07-24 03:54:50 +08:00
SnapOtterandGitHub 079fcd2631 fix(security): close the gaps a full 2.0 re-audit left open (#620)
Follow-up to a full re-audit of the 2.0 tree. Most prior findings were already
fixed; this closes the ones that were not:

- SAML assertion replay: validateInResponseTo ifPresent plus a Redis-backed
  CacheProvider, so a captured signed assertion cannot be replayed. ifPresent
  keeps IdP-initiated SSO working.
- MFA login challenge burned after 5 wrong TOTP codes.
- api_keys.key_prefix indexed; the per-request lookup was a full table scan.
- MAX_AI_JOBS_PER_USER caps a user's in-flight single-file AI jobs (the AI pool
  runs at concurrency 1). Batch and pipeline AI stay uncapped.
- MAX_WORKSPACE_SIZE_GB enforced instead of being dead config.
- SUBPROCESS_MEMORY_LIMIT_MB (default off) for the native media and doc engines;
  not applied to the AI sidecar.
- SVG sanitizer closes unquoted and whitespace-prefixed javascript: hrefs and
  the animateTransform/animateMotion/handler/mpath elements.
- Windows-style paths stripped from error output to match the Sentry scrubber.
- Postgres and Redis compose services get cap_drop plus pids_limit and cpus.
- .env.example ships MAX_SVG_SIZE_MB=50 (0 disabled the cap).

Adds security-focused unit and integration tests. typecheck, biome, and the
full unit and integration suites pass.
2026-07-23 00:18:16 +08:00
SnapOtterandGitHub 10a2aabe58 fix(deps): patch 4 HIGH/CRITICAL transitive CVEs (fast-uri, svgo, sharp, tar) (#619)
Scoped pnpm overrides for four transitive HIGH/CRITICAL CVEs; regenerating the lockfile also cleared brace-expansion.

- fast-uri >=4.1.1 (CVE-2026-16221, WHATWG backslash URL-parser differential; transitive via ajv)
- astro>svgo 4.0.2 (GHSA-2p49-hgcm-8545, removeScripts leaves executable scripts)
- astro>sharp 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs); runtime image-engine sharp ^0.35.3 untouched
- tar >=7.5.19 (CVE-2026-59873 CRITICAL, CVE-2026-59874)

trivy fs HIGH/CRITICAL 4->0; pnpm audit high 3->0; landing build + api typecheck pass.
2026-07-23 00:01:02 +08:00
SnapOtterandGitHub 44d8109486 fix: enforce settings authority boundaries (#618)
Close generic settings authorization bypasses and enforce per-setting authority, validation, redaction, transactional config import, and route-local write rate limiting.
2026-07-22 20:15:38 +08:00
0467e87bfe fix(download): reset the socket when a stream is shorter than Content-Length (#617)
The download route sets Content-Length from a stat and then streams the
object; when the stat size exceeds the bytes the stream yields (#590
"cause 2"), the client hangs on keep-alive framing waiting for a tail
that never arrives. Both send paths now run through a backpressure-safe
byte-counting Transform that resets the socket on a shortfall, so the
download fails at once instead of hanging. Adds a real-socket regression
test at the generic download route, the coverage gap #590 named.

Refs #590

Co-authored-by: harshjainnn <170849281+harshjainnn@users.noreply.github.com>
2026-07-22 02:03:54 +00:00
SnapOtterandGitHub 1f8a42e548 fix: enforce role authority for user management (#616)
Centralize role-authority enforcement across user management, role management, configuration import, SCIM, GDPR, and MFA mutations. Add regression coverage for delegated custom roles and protect higher-privilege accounts from reset, deletion, or takeover.
2026-07-22 01:23:15 +08:00
SnapOtterandGitHub 129e42b95c feat(feedback): gate onboarding survey on first processing, add prompt lifecycle events (#615)
Defers the onboarding usage survey to the instance's first successful processing (the worker writes a one-time onboarding.firstProcessedAt marker and the overlay gates on it), so it reaches engaged users instead of first-landing visitors.

Replaces the two questions telemetry already answers (modality preference from tool_used, install method from instance_started) with what it can't infer: prior tool, self-host motivation, and discovery source.

Adds feedback_prompt_shown and feedback_prompt_dismissed on all five feedback surfaces (usage survey, per-job prompt, admin install card, global nav dialog, search-miss) so skip and completion rates are measurable, not just submissions. New survey strings translated into all 20 non-English locales.
2026-07-21 16:31:30 +00:00
SnapOtterandGitHub b20bca3c3c fix(telemetry): data-quality pass (opt-in noise, onboarding split, file_count, OIDC) (#614)
Five fixes to the PostHog event stream, from an audit of what we actually collect versus what's flowing in. Each one is test-first.

## What changed

**Silenced the `$opt_in` noise.** `initAnalytics` called `opt_in_capturing()` on every page load to clear a stale opt-out flag, and posthog-js emits an `$opt_in` event on every call. That was 10k+ events a month (up to 55 per user) carrying no signal: analytics is on by default with an admin opt-out, so there is no per-user consent to record. Both call sites now pass `captureEventName: false`.

**Split the onboarding survey out of `feedback_submitted`.** The onboarding usage survey rode the same event as real feedback, so about 93% of "feedback" was actually onboarding profiling. It now emits `onboarding_survey_submitted`, so feedback metrics mean feedback again.

**Set `pipeline_executed.file_count`.** It was declared in the properties interface but never populated. A pure `pipelineExecutedProps` helper now derives it (batch size for a batch run, else 1) and is shared by the success and failure paths, which also drops a duplicated payload.

**Tracked OIDC login failures.** All six OIDC callback failure branches bumped the Prometheus counter and wrote an audit log but never emitted `auth_login_failed`. A `recordOidcFailure` helper mirrors the password path.

**Added `TELEMETRY.md`.** A contributor-facing event dictionary: every event, its properties, where it fires, and the privacy invariants, with the allowlists as source of truth. A drift test fails if any `ANALYTICS_EVENTS` value goes undocumented.

I left the published telemetry guide (`apps/docs/guide/telemetry.md`) alone. It is high-level and still accurate, and editing it would pull in the 21-locale stale-gate for no gain.

## Verification

- Unit (63 tests): `analytics-events`, `telemetry-doc-drift`, `api/analytics`, `web/analytics`, `worker.behavior`
- Integration (41 tests): `oidc-auth`, `feedback`
- Full typecheck across all 9 workspaces
- Biome clean on the changed files

All green locally.
2026-07-21 23:36:02 +08:00
SnapOtterandGitHub 6a0768b39d fix(telemetry): surface AI sidecar and DOMException failure reasons in Sentry (#612)
AI sidecar failures reached Sentry as 'Error: Error': the scrubber type-onlys plain Errors and the tool wrappers threw them from result.error. The bridge now exports toSidecarError(), wrapping the sidecar reason in a SafeError (memory-allocation text classifies as operational, the rest as bug); all 14 wrappers use it, plus the dispatcher crash/stdin/spawn rejection paths and parseStdoutJson. toBgRemovalError from #535 delegates to the shared helper.

On the web side, DOMExceptions report their specific name via err.name, so the NATIVE_ERRORS allowlist dropped the whole family's browser-authored messages. It now carries the full WebIDL DOMException name table; messages still pass through url/path redaction.

Bridge-mocking test files switched to importOriginal passthrough mocks.
2026-07-21 23:18:50 +08:00
SnapOtterandGitHub 82f5708193 fix(telemetry): fingerprint stackless uncaught errors so they stop collapsing (#611)
Stackless uncaught errors reached Sentry as a bare Error with no frames and collapsed into one ungroupable issue. beforeSend now fingerprints frameless events by safe identity (name, code, one-way hash of the message) so distinct crashes separate without leaking PII. Only frameless events are touched; an upstream fingerprint is never overridden.
2026-07-21 13:37:23 +00:00
SnapOtterandGitHub e537cb0401 fix(jobs): classify BullMQ stall (UnrecoverableError) as operational (#610)
BullMQ raises UnrecoverableError when a job loses its lock (a stall), e.g. a heavy upscale under memory pressure. We never throw it ourselves, so classifyError now treats it as operational (one warning per hour) instead of a bug. ReplyError stays a bug.
2026-07-21 21:23:12 +08:00
SnapOtterandGitHub e56edc659f fix(ai): advance the progress bar during upscale and background removal (#608)
RealESRGAN's enhance() and rembg's remove() run in one opaque call, so the
progress bar froze at 30% for the whole inference. Add a time-based
heartbeat that advances the bar in a background thread while the model runs
and stops when it returns, so the bar moves instead of freezing. Verified
end to end on a GPU box (forced CPU): a 55s upscale emitted 26 steady ticks
then completed; background removal too.

Fixes #591
2026-07-21 19:21:45 +08:00
SnapOtterandGitHub e3c93333ea docs(deploy): note that proxy response buffering stalls downloads (#607)
Document that a response-buffering reverse proxy is the usual cause of a
self-hosted download that starts but never finishes, point at the
X-Accel-Buffering: no safety net (#604), and call out downloads alongside
SSE in the nginx and Caddy examples.

Refs #590
2026-07-21 18:40:39 +08:00
SnapOtterandGitHub c706a9e8a1 docs(deploy): pin pg_isready to POSTGRES_DB and flag the default password (#606)
Add -d snapotter to the guide's Compose healthcheck examples so they match
the shipped compose fix (#595), and note "change this" next to the default
POSTGRES_PASSWORD. English guide only; locale docs regenerate through the
i18n pipeline.

Refs #592
2026-07-21 18:35:24 +08:00
SnapOtterandGitHub d43208b85e fix(ai): warn that upscale and background removal are slow without a GPU (#605)
Add a one-line notice to the upscale and background-removal settings that
heavy AI runs much slower without a GPU and a large image can take minutes,
so a CPU-only self-hoster isn't caught off guard by a slow run. Translated
into all 21 locales.

Refs #591
2026-07-21 17:55:11 +08:00
SnapOtterandGitHub 89d75853f4 fix(download): ask reverse proxies not to buffer file downloads (#604)
Send X-Accel-Buffering: no on file download responses so nginx and
compatible reverse proxies stream them through instead of buffering, the
usual cause of a self-hosted download that "starts but never finishes".
The app already delivers exactly Content-Length bytes; a new real-socket
test proves it for both the collated PDF and the multi-file ZIP.

Refs #590
2026-07-21 17:02:12 +08:00
SnapOtterandGitHub e7ffb37e98 feat(image): add rounded-square and squircle crop tool (#602)
Adds a Rounded Crop image tool for logo, favicon, and app-icon work. It masks the framed square to a rounded rectangle (with a corner-radius control) or an iOS-style squircle, reusing circle-crop's zoom/offset framing, border ring, background fill, and output-size options. Includes translations across all 21 locales.

Closes #601
2026-07-21 16:48:48 +08:00
SnapOtterandGitHub 7d37f6e6f5 fix(pdf): flag scanned PDFs in pdf-to-text and serve text as UTF-8 (#603)
When a PDF has no text layer (scanned or image-only), pdf-to-text now
returns a 422 that points at the OCR tool instead of a silent empty file,
and text downloads carry charset=utf-8 so UTF-8 Arabic renders correctly
when the .txt is viewed inline.

Fixes #589
2026-07-21 16:26:43 +08:00
SnapOtterandGitHub 43334324c4 fix(api): contain library stored-name path traversal (#600)
The library file-storage helpers joined FILES_STORAGE_PATH with a database stored_name and never checked containment, so a crafted name could read or delete files outside the storage root after a malicious 1.x SQLite import (which copies stored_name verbatim). Add assertSafeStoredName() and apply it in every helper that resolves a stored name to a path, matching the containment guard object-storage already uses.

Reported by Alpesh Bhagwatkar.
2026-07-21 07:30:46 +00:00
SnapOtterandGitHub 37c915c3da fix(jobs): make timeout messages tool-agnostic and CPU-aware (#596)
Replace the job timeout message that hardcoded "background-removal" for
every tool with a tool-agnostic one that sets the CPU-vs-GPU expectation,
the usual reason heavy AI times out on modest hardware. The client-side
SSE stall message gets the same treatment. Both stay under friendlyError's
280-char limit so the guidance reaches the user instead of collapsing to a
generic "Processing failed".

Refs #591
2026-07-21 15:22:57 +08:00