mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
c2903872bb9361a0894dcaf0fe29b0f606b0caf2
2123
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c2903872bb |
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)) ([ |
||
|
|
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. |
||
|
|
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. |
||
|
|
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)) ([ |
||
|
|
0a02f3b8e4 | docs: add release notes for v2.2.0 | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |