Skip to content

feat(workers): optimize downloaded banner images - #3165

Merged
MohamedBassem merged 4 commits into
karakeep-app:mainfrom
MohamedBassem:feat/shared-image-optimization
Oct 10, 2026
Merged

MohamedBassem merged 4 commits into
karakeep-app:mainfrom
MohamedBassem:feat/shared-image-optimization

Conversation

@MohamedBassem

Copy link
Copy Markdown
Member

No description provided.

- Convert static JPEG and PNG banners to resized WebP
- Preserve animated and already optimized image formats
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-10T23:47:12.092502Z 46cb243 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor suggestion inline.

Reviewed changes

  • Banner image optimization: new optimizeBannerImage (packages/shared-server/src/bannerImages.ts) downscales JPEG/PNG banners to WebP (max width 1200, quality 80, EXIF-rotated), returning null for gifs, webp, and multi-frame inputs so they're stored as-is.
  • Crawler banner path: the banner download is split out of downloadAndStoreFile into downloadAndStoreBanner, with new fetchAsset/fetchImage helpers and storeDataUriAsset reduced to the pure decodeDataUriImage. Banners are now optimized in memory and stored via saveBufferAsset instead of the temp-file round trip.
  • Dependency: adds sharp to apps/workers and @karakeep/shared-server, imported lazily. The lockfile already resolves sharp@0.35.5 (web/landing depend on it) and pnpm-workspace.yaml already allows its build script.

The data: handling removed from downloadAndStoreFile is safe: its only caller is handleAsAssetBookmark, whose URL is restricted to http/https by isAllowedBookmarkUrl, and banner data URIs are handled by downloadAndStoreBanner.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/shared-server/src/bannerImages.ts Outdated
@greptile-apps

greptile-apps Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[Medium impact] Adds image optimization for downloaded banner images.

The PR appears safe to merge, with non-blocking improvements to conversion fallback, cancellation checks, and output size.

Findings

  1. P2 Conversion errors drop banners ▶
  2. P2 Timed-out crawls leave files ▶
  3. P2 Optimized banners can grow ▶
Fix with agent prompt
### Issue 1
apps/workers/workers/crawlerWorker.ts:2021-2022
`?? banner` only preserves the original when `optimizeBannerImage` returns `null`. If `sharp` throws while reading or converting a downloaded image, the outer catch drops the banner instead. Keep conversion best-effort: log the conversion error and save the original, while still letting crawl cancellation stop the job.

### Issue 2
apps/workers/workers/crawlerWorker.ts:2023-2028
A timeout during `optimizeBannerImage` does not stop conversion, so this code still writes the banner. The caller then throws before adding its database row, leaving an unused file in storage. Retries can repeat that write. Check `ctx.abortSignal` after conversion and before `saveBufferAsset`.

### Issue 3
packages/shared-server/src/bannerImages.ts:34
WebP at quality 80 is not always smaller, especially for small or heavily compressed PNGs. This always stores the converted version, so an optimization can increase storage and download size. Compare the buffer sizes and return `null` when conversion does not save bytes.

```suggestion
  if (optimized.byteLength >= image.byteLength) {
    return null;
  }
  return { image: optimized, contentType: ASSET_TYPES.IMAGE_WEBP };
```

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Downloads banner images into memory and converts JPEG/PNG banners to WebP, with a maximum width of 1200 pixels.

  • Crawled JPEG and PNG banners are saved as smaller WebP images.

Reviews (1) · Last reviewed commit: "feat(workers): optimize downloaded banne..." · Reviewed by Greptile

Comment on lines +2021 to +2022
const toStore =
(await optimizeBannerImage(banner.image, banner.contentType)) ?? banner;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Conversion errors drop banners

?? banner only preserves the original when optimizeBannerImage returns null. If sharp throws while reading or converting a downloaded image, the outer catch drops the banner instead. Keep conversion best-effort: log the conversion error and save the original, while still letting crawl cancellation stop the job.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/workers/workers/crawlerWorker.ts
Line: 2021-2022

Comment:
**Conversion errors drop banners**

`?? banner` only preserves the original when `optimizeBannerImage` returns `null`. If `sharp` throws while reading or converting a downloaded image, the outer catch drops the banner instead. Keep conversion best-effort: log the conversion error and save the original, while still letting crawl cancellation stop the job.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +2023 to +2028
const assetId = await saveBufferAsset(
ctx,
"banner image",
toStore.image,
{ contentType: toStore.contentType },
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Timed-out crawls leave files

A timeout during optimizeBannerImage does not stop conversion, so this code still writes the banner. The caller then throws before adding its database row, leaving an unused file in storage. Retries can repeat that write. Check ctx.abortSignal after conversion and before saveBufferAsset.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/workers/workers/crawlerWorker.ts
Line: 2023-2028

Comment:
**Timed-out crawls leave files**

A timeout during `optimizeBannerImage` does not stop conversion, so this code still writes the banner. The caller then throws before adding its database row, leaving an unused file in storage. Retries can repeat that write. Check `ctx.abortSignal` after conversion and before `saveBufferAsset`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

.resize({ width: BANNER_MAX_WIDTH, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
return { image: optimized, contentType: ASSET_TYPES.IMAGE_WEBP };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Optimized banners can grow

WebP at quality 80 is not always smaller, especially for small or heavily compressed PNGs. This always stores the converted version, so an optimization can increase storage and download size. Compare the buffer sizes and return null when conversion does not save bytes.

Suggested change
return { image: optimized, contentType: ASSET_TYPES.IMAGE_WEBP };
if (optimized.byteLength >= image.byteLength) {
return null;
}
return { image: optimized, contentType: ASSET_TYPES.IMAGE_WEBP };
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/shared-server/src/bannerImages.ts
Line: 34

Comment:
**Optimized banners can grow**

WebP at quality 80 is not always smaller, especially for small or heavily compressed PNGs. This always stores the converted version, so an optimization can increase storage and download size. Compare the buffer sizes and return `null` when conversion does not save bytes.

```suggestion
  if (optimized.byteLength >= image.byteLength) {
    return null;
  }
  return { image: optimized, contentType: ASSET_TYPES.IMAGE_WEBP };
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 277fa0dd73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
// Lazily loaded as it's a native module that most users of this package don't need.
const { default: sharp } = await import("sharp");
const pipeline = sharp(image);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cap decoded pixels before optimizing untrusted banners

When banner caching is enabled, image comes from page-controlled metadata while fetchImage only caps compressed bytes; sharp(image) therefore relies on Sharp's roughly 268 MP default, so a compact decompression-bomb PNG can force hundreds of millions of pixels through native decoding and resizing. That work is not connected to the crawler's AbortSignal, so it can exhaust CPU/native memory and hold the crawler slot beyond the job timeout; initialize Sharp with a banner-sized limitInputPixels or reject excessive dimensions before conversion.

Useful? React with 👍 / 👎.

return null;
}
const toStore =
(await optimizeBannerImage(banner.image, banner.contentType)) ?? banner;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to the original when WebP conversion rejects

When a JPEG/PNG is valid for storage but Sharp cannot encode it—for example, a 100x20,000 PNG is left at that height by the width-only withoutEnlargement resize and exceeds WebP's 16,383-pixel dimension limit—this await rejects and the enclosing catch returns null instead of using banner. This makes a SingleFile data-URI banner disappear because its external imageUrl is deliberately not stored, whereas the previous path saved the original; catch optimization failures separately and retain the original buffer while still propagating aborts.

Useful? React with 👍 / 👎.

Comment thread apps/workers/workers/crawlerWorker.ts Outdated
Comment on lines +1997 to +1999
chunks.push(Buffer.from(chunk));
}
return { image: Buffer.concat(chunks), contentType };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stream banners that will not be transcoded

When a remote GIF or WebP (which optimizeBannerImage returns unchanged) approaches MAX_ASSET_SIZE_MB, this path retains all copied chunks and allocates another full buffer in Buffer.concat—about 100 MB at the default 50 MB limit, per concurrent crawl. The previous helper streamed these assets through a temporary file, so a large animated banner can now exhaust low-memory or multi-worker deployments for no optimization benefit; keep the streaming path for formats that bypass Sharp or impose a much smaller banner-specific memory cap.

Useful? React with 👍 / 👎.

…'t help

- Return null from optimizeBannerImage on sharp errors so the original banner is stored instead of being dropped.
- Skip the webp version when it isn't smaller than the original.
- Check for crawl timeouts before storing the optimized banner.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the delta since the prior pullfrog review (277fa0d): the follow-up fix(workers): keep the original banner when optimizing fails or doesn't help commit.

  • Optimization failures now fall back to the original: optimizeBannerImage wraps the sharp pipeline in try/catch, logs a warning, and returns null, so the call site's ?? banner stores the original bytes instead of dropping the banner entirely.
  • Skips non-helpful WebP: the converted buffer is discarded when optimized.byteLength >= image.byteLength, preserving the smaller/original encoding.
  • Abort observed before storing: downloadAndStoreBanner calls ctx.abortSignal.throwIfAborted() after optimization, so a crawl timeout that lands mid-conversion isn't silently swallowed between the fetch and saveBufferAsset.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

- Pass a 50MP limitInputPixels to sharp so a small compressed banner can't decode into a huge image. Larger banners are stored as is.
- Don't copy each downloaded chunk when buffering the banner in memory.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the delta since the prior pullfrog review (19e526e): the follow-up fix(workers): cap decoded banner pixels and avoid copying banner chunks commit.

  • Capped decoded banner pixels: optimizeBannerImage now builds sharp with limitInputPixels: BANNER_MAX_INPUT_PIXELS (50M), so a small compressed image that would decode into a huge pixel count fails fast and the original bytes are stored instead — a decompression-bomb guard with no data-loss downside.
  • Avoided a redundant buffer copy: fetchImage pushes stream chunks through directly (typeof chunk === "string" ? Buffer.from(chunk) : chunk) instead of always copying, and Buffer.concat still copies on the final assembly, so the stored bytes are unchanged.

The prior inline concern (returning null rather than throwing on sharp failure) remains correctly implemented from 19e526e, and the new pixel cap routes through that same fallback.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

- Grow a single resizable ArrayBuffer while downloading the banner instead of collecting chunks and concatenating them, so the banner isn't held twice in memory.
- Write the asset buffer directly in the filesystem asset store instead of copying it first.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor suggestion inline.

Reviewed changes

Reviewed the delta since the prior pullfrog review (63468ba): the follow-up fix(workers): buffer banners in a resizable ArrayBuffer commit.

  • Banner download now buffers in place: fetchImage grows a single resizable ArrayBuffer (maxByteLength = maxAssetSizeMb) while streaming and returns a Buffer view sharing that store, replacing the chunk-array + Buffer.concat approach that held the banner twice.
  • Filesystem asset store writes the buffer directly: LocalFileSystemAssetStore.saveAsset drops the Uint8Array.from(asset) copy and hands the Buffer straight to writeFile (byte-identical output).

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

);
}
buffer.resize(offset + chunk.length);
bytes.set(Buffer.from(chunk), offset);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunk is already a Uint8Array (the response body is a web ReadableStream), and Uint8Array#set accepts any ArrayLike<number>, so bytes.set(chunk, offset) writes it directly. The Buffer.from(chunk) in between allocates and copies every chunk — the extra copy this commit set out to eliminate.

@MohamedBassem
MohamedBassem merged commit ac5d788 into karakeep-app:main Oct 10, 2026
6 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46cb243055

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
}
buffer.resize(offset + chunk.length);
bytes.set(Buffer.from(chunk), offset);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid cloning every downloaded banner chunk

When a remote banner is large, the new resizable-buffer path still calls Buffer.from(chunk), but fetchWithProxy uses node-fetch, whose binary response stream already yields Buffers. This allocates a second backing store for every chunk before Uint8Array#set copies it into the growing buffer; those temporary external allocations can accumulate between GCs and add a full download's worth of allocation churn, undermining the memory reduction for near-MAX_ASSET_SIZE_MB banners and concurrent crawls. Set the fetched buffer directly, converting only an actual string chunk.

Useful? React with 👍 / 👎.

GiorgioAresu pushed a commit to GiorgioAresu/home-ops that referenced this pull request Oct 23, 2026
…➔ 0.34.1) (#2417)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/karakeep-app/karakeep](https://github.com/karakeep-app/karakeep) | minor | `0.33.2` → `0.34.1` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/6) for more information.

---

### Release Notes

<details>
<summary>karakeep-app/karakeep (ghcr.io/karakeep-app/karakeep)</summary>

### [`v0.34.1`](https://github.com/karakeep-app/karakeep/releases/tag/v0.34.1): 0.34.1

[Compare Source](karakeep-app/karakeep@v0.34.0...v0.34.1)

This is a security release on top of 0.34.0. The docker images move from the pinned `node:24.18.1-slim` back to `node:24-slim` (currently Node 24.22.1) to pick up the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Among other fixes, these patch a Brotli decoder out-of-bounds write (CVE-2026-111497) and several TLS certificate hostname validation bypasses (CVE-2026-105483, CVE-2026-106069, CVE-2026-106072). Thanks [@&#8203;eriktews](https://github.com/eriktews) for the fix ([#&#8203;3217](karakeep-app/karakeep#3217))!

The node version was pinned in 0.33.2 because of the better-sqlite3 crash loop ([#&#8203;2989](karakeep-app/karakeep#2989)). The upstream fix for that shipped in Node 24.22, so the pin is no longer needed.

### [`v0.34.0`](https://github.com/karakeep-app/karakeep/releases/tag/v0.34.0): 0.34.0

[Compare Source](karakeep-app/karakeep@v0.33.2...v0.34.0)

##### 0.34.0

Welcome to the `0.34.0` release of Karakeep (and sorry for the long wait)! The mobile app gets most of the attention this time: **Automatic offline downloads** for recent bookmarks and particular lists, **tablet layouts**, tag management, public lists, collaborators and attachments, all of which used to need the web app. The crawler got a big anti-bot overhaul: it now drives real **Google Chrome through patchright**, so far fewer sites will hand you a "Just a moment..." page instead of the article. There are also a lot of memory and performance improvements, along with tons of bug fixes.

Thanks to everyone who contributed to this release: [@&#8203;justadityaraj](https://github.com/justadityaraj), [@&#8203;InventorB](https://github.com/InventorB), [@&#8203;ryanchou1994](https://github.com/ryanchou1994), [@&#8203;NoiceHax](https://github.com/NoiceHax), [@&#8203;0-0mfuji](https://github.com/0-0mfuji), [@&#8203;eriktews](https://github.com/eriktews), [@&#8203;qwist1233-cpu](https://github.com/qwist1233-cpu), [@&#8203;lightsabit](https://github.com/lightsabit), [@&#8203;BrownieCoder](https://github.com/BrownieCoder), [@&#8203;itz-puneet](https://github.com/itz-puneet), [@&#8203;jreiml](https://github.com/jreiml), [@&#8203;deepcfr](https://github.com/deepcfr), [@&#8203;mislav](https://github.com/mislav), [@&#8203;pradeepb28](https://github.com/pradeepb28), [@&#8203;Kyzcreig](https://github.com/Kyzcreig), [@&#8203;gowinder](https://github.com/gowinder), [@&#8203;samuel-sarmah](https://github.com/samuel-sarmah), and everyone who triaged bugs or shared feedback.

*Note: the changes mentioned here for the extension and mobile releases will need to go through the different store (apple, google, mozilla) reviews. So it might take a couple of days until all of them are released.*

> If you enjoy using Karakeep, consider supporting the project [here ☕️](https://buymeacoffee.com/mbassem) or via GitHub [here](https://github.com/sponsors/MohamedBassem). Also share Karakeep's managed offering (☁️ [link](https://cloud.karakeep.app)) with your friends who're not into selfhosting.

<a href="https://www.buymeacoffee.com/mbassem" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="auto" height="50" ></a>

##### New Features 🚀

- **Karakeep's auth moved from next-auth to better-auth**:
  - This is an infrastructure change, but it's a big one. Karakeep moved its auth library to better-auth (away from next-auth). This will unlock a lot of new features in the future (e.g. 2fac, oauth login in the mobile app, hosted MCP auth, etc).
  - This migration is going to happen automatically after the upgrade BUT there are some caveats, please read the "Upgrading" section.
- **Automatic Offline Downloads**: 0.33 let you save single bookmarks for offline reading on the mobile app. In this release, you can let karakeep automatically download recent bookmarks for you while the app is open, and also mark specific lists to be always fully available offline.
- **Mobile app catching up with the web**:
  - Tablet layout showing multiple columns on wide screens.
  - Tag management: rename and delete tags from the app ([`fc401d0`](karakeep-app/karakeep@fc401d02)).
  - Manage public lists and list collaborators ([`292c13a`](karakeep-app/karakeep@292c13a5)).
  - View and download bookmark attachments ([`27774f9`](karakeep-app/karakeep@27774f97)).
  - A new usage statistics screen ([`6563ee1`](karakeep-app/karakeep@6563ee14)).
  - Pick a parent list, choose a list icon, and create lists straight from the "Manage Lists" page ([`7ca5ee2`](karakeep-app/karakeep@7ca5ee23), [`428d1f2`](karakeep-app/karakeep@428d1f22), [`f977712`](karakeep-app/karakeep@f9777128)).
- **A crawler that gets blocked less**:
  - The crawler now drives the browser with [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), a Playwright fork that doesn't leak the automation signals that bot-protection scripts look for ([`af8b0eb`](karakeep-app/karakeep@af8b0eb0)).
  - The `karakeep-chrome` image now ships Google Chrome stable instead of Chrome Headless Shell, as protected sites block the headless shell far more often ([`ea57af1`](karakeep-app/karakeep@ea57af18)). This comes at the cost of the chrome image getting bigger unfortunatly.
  - The user agent now comes from the actual browser instead of a hardcoded Chrome 124 one ([`254174a`](karakeep-app/karakeep@254174ac)).
  - Cloudflare's "Just a moment..." and DataDome's device check clear on their own after a few seconds. The crawler now waits for them (up to `CRAWLER_CHALLENGE_WAIT_SEC`, default 15s) instead of saving the challenge page ([`1800346`](karakeep-app/karakeep@18003460)).
  - New `CRAWLER_BROWSER_TIMEZONE` to make the browser's timezone match your proxy's location, as a UTC browser behind a non-UTC proxy is a strong bot signal ([`ba8c188`](karakeep-app/karakeep@ba8c1885)).
- **On-demand video preservation** ([#&#8203;3183](karakeep-app/karakeep#3183)): A new "Preserve Video" bookmark action downloads a single bookmark's video with yt-dlp. You can now keep automatic video downloads off and still preserve the ones you care about.
- **Broken links management revamp** ([#&#8203;3105](karakeep-app/karakeep#3105)): The broken links settings page now groups issues by category and supports search, sorting, retrying and bulk deletion.
- **Search**: New `has:notes` and `has:highlights` qualifiers by [@&#8203;BrownieCoder](https://github.com/BrownieCoder) ([#&#8203;3053](karakeep-app/karakeep#3053)).
- **Others**:
  - New `CRAWLER_FULL_PAGE_ARCHIVE_MAX_SIZE_MB` to discard full-page archives above a certain size (media-heavy pages can produce 1GB+ archives that never render) by [@&#8203;NoiceHax](https://github.com/NoiceHax) ([#&#8203;3054](karakeep-app/karakeep#3054)).
  - Pages uploaded as precrawled archives (e.g. SingleFile uploads, and client-side crawling) now get a screenshot too ([#&#8203;3161](karakeep-app/karakeep#3161)).
  - Banner images are now resized and converted to WebP to save storage ([#&#8203;3165](karakeep-app/karakeep#3165)).
  - The default tagging model is now `gpt-6-luna` which costs half as much as `gpt-5.6-luna` used to cost (thanks OpenAI?).

##### UX Improvements ✨

- A visual refresh of various web app components. More modern looking dialogs, buttons, dropdown menus, tooltips, etc.
- Added a "System" theme option that follows your OS by [@&#8203;deepcfr](https://github.com/deepcfr) ([#&#8203;3159](karakeep-app/karakeep#3159)).
- The bookmark preview page now has the full bookmark options menu in its action bar ([`24fb655`](karakeep-app/karakeep@24fb655c)).
- The "delete unused tags" button is now a bit more grown up ([`acbac6a`](karakeep-app/karakeep@acbac6ad)).
- Home/End now move the cursor in the search bar by [@&#8203;eriktews](https://github.com/eriktews) ([#&#8203;3110](karakeep-app/karakeep#3110)).
- The browser's theme color now follows dark mode by [@&#8203;itz-puneet](https://github.com/itz-puneet) ([#&#8203;3051](karakeep-app/karakeep#3051)).
- Mobile:
  - Lists are sorted by name, like on the web, by [@&#8203;justadityaraj](https://github.com/justadityaraj) ([#&#8203;3040](karakeep-app/karakeep#3040)).
  - Bigger toolbar tap targets by [@&#8203;jreiml](https://github.com/jreiml) ([#&#8203;2813](karakeep-app/karakeep#2813)).
  - Better looking bookmark cards when there's no metadata ([`2b2771a`](karakeep-app/karakeep@2b2771a6)).

##### Fixes 🐛

- **Performance and memory**:
  - Fix a server memory leak caused by the query cache ([#&#8203;3138](karakeep-app/karakeep#3138)).
  - Workers and web only load the dependencies of features that are enabled cutting memory usage for most setups. You should expect a 40MB drop in RSS with this release.
  - Prometheus default metrics are no longer collected when Prometheus isn't configured ([#&#8203;2397](karakeep-app/karakeep#2397)). This fixes a regerssion introduced in 0.29 and should result into a lower baseline CPU usage.
  - Faster tag searches by [@&#8203;0-0mfuji](https://github.com/0-0mfuji) ([#&#8203;3089](karakeep-app/karakeep#3089)).
  - Search indexing now defaults to 50 workers (instead of 1) for better batching. Should result in faster indexing for large imports.
  - SQLite now runs `PRAGMA optimize` on open and daily ([`355a0b0`](karakeep-app/karakeep@355a0b09)).
  - Faster lists page and list stats queries and a smaller main web bundle ([`2d7cc4b`](karakeep-app/karakeep@2d7cc4bd), [`44ea028`](karakeep-app/karakeep@44ea0284), [`b640f15`](karakeep-app/karakeep@b640f15b)).
- **Hardening**:
  - Upload body size is now enforced before parsing the multipart body ([`3e22b65`](karakeep-app/karakeep@3e22b659)).
  - Asset downloads now respect client backpressure instead of buffering in memory ([`b5cab5b`](karakeep-app/karakeep@b5cab5be)).
  - Pagination limits are now validated on all tRPC endpoints ([`94e1ea2`](karakeep-app/karakeep@94e1ea21)).
  - Content length is capped before tokenizing AI prompts ([`ef07e91`](karakeep-app/karakeep@ef07e913)).
  - Upgraded Next.js to 16.4.1 for its October security release ([#&#8203;3196](karakeep-app/karakeep#3196)).
- Collaborators' bookmarks now show up in public lists by [@&#8203;NoiceHax](https://github.com/NoiceHax) ([#&#8203;3019](karakeep-app/karakeep#3019)).
- Moving a list under one of its own sublists is now rejected, as the resulting cycle could send list deletion into an infinite loop. Reported by [@&#8203;Vertextheonesolution](https://github.com/Vertextheonesolution) (GHSA-892w-x93w-2fqp, [#&#8203;3199](karakeep-app/karakeep#3199)).
- Fix bookmark pagination tie breakers that could skip or duplicate bookmarks ([`135f116`](karakeep-app/karakeep@135f1163)).
- Fix the headers timeout for AI inference jobs with slow providers by [@&#8203;eriktews](https://github.com/eriktews) ([#&#8203;2888](karakeep-app/karakeep#2888)).
- Fix embedding generation with providers that don't default to float embeddings ([#&#8203;3009](karakeep-app/karakeep#3009)).
- Bookmarks deleted while being indexed no longer dangle in meilisearch ([`bca289d`](karakeep-app/karakeep@bca289d2)).
- The RSS fetcher now identifies itself with a karakeep specific user agent ([`adf399b`](karakeep-app/karakeep@adf399be)).
- If one of the workers fails, the whole worker process now crashes instead of silently running without it, so your container's restart policy can kick in ([`ae9e23b`](karakeep-app/karakeep@ae9e23b3)).
- Fix `.jpg` uploads being rejected in the attachment box ([`2434e65`](karakeep-app/karakeep@2434e657)).
- Fix the header shrinking in Safari ([`3c44114`](karakeep-app/karakeep@3c441143)).
- Fix duplicate "sort by" in the all tags page ([`f5e56b4`](karakeep-app/karakeep@f5e56b42)).
- Fix the sidebar's "+" (new list) button messing up the bookmark preview modal when clicked while a bookmark is open by [@&#8203;samuel-sarmah](https://github.com/samuel-sarmah) ([#&#8203;3192](karakeep-app/karakeep#3192)).
- Make `/run` owned by `node` in the docker image so non-root setups work by [@&#8203;lightsabit](https://github.com/lightsabit) ([#&#8203;3114](karakeep-app/karakeep#3114)).
- Mobile:
  - Fix lost and duplicated Android shares after the app gets killed ([`e7fbaab`](karakeep-app/karakeep@e7fbaab7)). This has been a long standing problem, and should signficantly improve share reliability for android.
  - Ignore Android's unreliable internet reachability checks that marked the app offline ([#&#8203;3127](karakeep-app/karakeep#3127)).
  - Fix the server save button getting hidden by the keyboard by [@&#8203;InventorB](https://github.com/InventorB) ([#&#8203;3010](karakeep-app/karakeep#3010)).
  - Markdown list bullets and numbers now use the foreground color by [@&#8203;ryanchou1994](https://github.com/ryanchou1994) ([#&#8203;3046](karakeep-app/karakeep#3046)).
  - Fix lists not getting auto refreshed when a bookmark is added to it ([`f4efff9`](karakeep-app/karakeep@f4efff99)) and vertical images in bookmark info ([`482369b`](karakeep-app/karakeep@482369b7)).
  - Fix a bug that was causing mobile API keys to not be revoked on mobile app logout ([`1820693`](karakeep-app/karakeep@1820693e)).
- Added missing Turkish translations by [@&#8203;qwist1233-cpu](https://github.com/qwist1233-cpu) ([#&#8203;3111](karakeep-app/karakeep#3111)).

##### For Developers 🛠️

- The MCP server has been upgraded to the MCP v2 SDK ([`39df6c6`](karakeep-app/karakeep@39df6c6e)).
- The CLI now bundles the official Karakeep skill ([`baf0b1b`](karakeep-app/karakeep@baf0b1bb)).
- Karakeep now serves `/.well-known/api-catalog`, `/.well-known/change-password` and `/.well-known/security.txt` ([`2620d9e`](karakeep-app/karakeep@2620d9e6)).
- The docs now have an `llms.txt` for agents ([`a8450b3`](karakeep-app/karakeep@a8450b3c)).
- Translations have moved away from Weblate and to an LLM driven workflow.
- Clarified `LOG_LEVEL` options in the docs by [@&#8203;mislav](https://github.com/mislav) ([#&#8203;3149](karakeep-app/karakeep#3149)).

##### Community Projects 💡

*Checkout our community projects page [here](https://docs.karakeep.app/community-projects).*

- [Alfred Karakeep](https://github.com/Kyzcreig/alfred-karakeep): An Alfred workflow for inline bookmark search using Karakeep's query language by [@&#8203;Kyzcreig](https://github.com/Kyzcreig) ([#&#8203;2974](karakeep-app/karakeep#2974)).
- [Supasend](https://supasend.app/#karakeep): A quick capture iOS app for sending photos and links to Karakeep by [@&#8203;pradeepb28](https://github.com/pradeepb28) ([#&#8203;3077](karakeep-app/karakeep#3077)).
- [KaraClone](https://github.com/gowinder/karaclone): A reimplementation of the Karakeep server API that runs on the Cloudflare Workers free tier by [@&#8203;gowinder](https://github.com/gowinder) ([#&#8203;3124](karakeep-app/karakeep#3124)).

##### Screenshots 📸

##### Automatic Offline Download

<img width="603" height="1311" alt="Screenshot 2026-10-17 at 10 27 34 PM" src="https://github.com/user-attachments/assets/02f2344e-27ee-43d7-b8dd-d74fba936280" />

##### Broken Links Page

<img width="2402" height="1250" alt="Screenshot 2026-10-17 at 22 27 11@&#8203;2x" src="https://github.com/user-attachments/assets/0df7d9c9-63b5-4739-8e55-6135e95a14f1" />

##### Upgrading 📦

**Things to know before upgrading:**

- **Everyone gets signed out once.** The move to better-auth ([#&#8203;3140](karakeep-app/karakeep#3140)) switches web sessions from JWTs to database sessions, so all web users will need to sign in again. Mobile apps and API keys are not affected.
- **Auth env changes**:
  - This release will automatically lowercase all email stored in the database (in prep for making email case insensitive), except accounts that will cause a conflict (two accounts that differ only in email case). You *Must* fix those conflicts before upgrading (by deleting the unused one). You'll get locked out if you have two conflicting accounts (neither of which are lowercase). If you get locked out, change the email address in the sqlite database to be lowercase to gain access back.
  - New `AUTH_TRUSTED_ORIGINS` (default `*`) to restrict which origins can sign in and be redirected to on top of `NEXTAUTH_URL`.
  - `NEXTAUTH_URL` and `NEXTAUTH_SECRET` keep their names and are still required.
- **Migrate to the `karakeep-chrome` image if you haven't already.** Check the migration guide [here](https://docs.karakeep.app/administration/chrome-image-migration). Browserless (mostly unraid users) will continue to work but will miss out on the stealth improvements. It's advised to migrate to karakeep-chrome's unraid template maintained by [@&#8203;Collectathon](https://github.com/Collectathon).

To upgrade:

- If you're using KARAKEEP_VERSION=release, run `docker compose pull && docker compose up -d`.
- If you're pinning it to a specific version, upgrade the version and then run `docker compose pull && docker compose up -d`.

</details>

---

### Configuration

📅 **Schedule**: (in timezone Europe/Rome)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTEuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE2Ny41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL21pbm9yIl19-->

Reviewed-on: https://git.aresu.eu/GiorgioAresu/home-ops/pulls/2417
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant