Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.4.3

A file too big to hold is turned away, not a crash.

- Picking a very large file to attach used to take the app down: it read the
whole thing into memory before anything could stop it. Attachments are
capped at 50 MB now, and anything larger is refused up front with a message,
so the app stays standing.

## 0.4.2

Backups survive a real attachment.
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ android {
applicationId = "io.github.munzzyy.magpie"
minSdk = 29
targetSdk = 36
versionCode = 402
versionName = "0.4.2"
versionCode = 403
versionName = "0.4.3"
}

buildTypes {
Expand Down
20 changes: 18 additions & 2 deletions app/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { isWrapper, wrapperVersion, shareOut, saveOut, canCapture, capturePhoto,
import { isBundled } from "./env.js";
import { setLocale, resolveLocale, translateDom, t, LOCALE_CHOICES } from "./i18n.js";

const VERSION = "0.4.2";
const VERSION = "0.4.3";

globalThis.__magpieErrors = [];
window.addEventListener("error", (ev) => __magpieErrors.push(String(ev.message)));
Expand Down Expand Up @@ -44,6 +44,12 @@ let pendingAttach = null;
let exportBlob = null;
let exportState = null;
let entryUrls = [];

// An attachment is read, sealed, and base64-encoded whole in memory, so a very
// large file exhausts RAM and the OS kills the app before it lands. Refuse it up
// front, by File.size, before anything reads it into memory.
const MAX_ATTACH_MB = 50;
const MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024;
// The last-rendered, already-decrypted rows: search filters this in memory
// only, never re-touches the vault, and is thrown away on lock.
let currentRows = [];
Expand Down Expand Up @@ -222,6 +228,10 @@ async function openEntry(entry, hash) {
// ------------------------------------------------------------------- add

function setPendingAttach(bytes, name, mime) {
if (bytes.length > MAX_ATTACH_BYTES) {
toast(t("{name} is too big to attach; the limit is {mb} MB.", { name, mb: MAX_ATTACH_MB }));
return;
}
pendingAttach = { bytes, name, mime };
const line = $("attach-name");
line.textContent = t("Attached: {name} ({kb} KB)", {
Expand Down Expand Up @@ -477,8 +487,14 @@ function wireEvents() {

$("btn-attach").addEventListener("click", () => $("attach-input").click());
$("attach-input").addEventListener("change", async () => {
const files = [...$("attach-input").files];
const picked = [...$("attach-input").files];
$("attach-input").value = "";
if (!picked.length) return;
const files = picked.filter((f) => f.size <= MAX_ATTACH_BYTES);
if (files.length < picked.length) {
const big = picked.find((f) => f.size > MAX_ATTACH_BYTES);
toast(t("{name} is too big to attach; the limit is {mb} MB.", { name: big.name, mb: MAX_ATTACH_MB }));
}
if (!files.length) return;
const [first, ...rest] = files;
// One pick, N chained entries: the first file stages this entry like
Expand Down
2 changes: 2 additions & 0 deletions app/js/strings-es.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const es = {
"{count} archivo(s) compartidos más en espera; cada uno será su propia entrada.",
"Could not save the entry.": "No se pudo guardar la entrada.",
"Could not read the shared file.": "No se pudo leer el archivo compartido.",
"{name} is too big to attach; the limit is {mb} MB.":
"{name} es demasiado grande para adjuntar; el límite es de {mb} MB.",
"Could not build the export.": "No se pudo generar la exportación.",
"Unlock to attach the shared file.": "Desbloquea para adjuntar el archivo compartido.",
"Unlock to attach your photo.": "Desbloquea para adjuntar tu foto.",
Expand Down
2 changes: 1 addition & 1 deletion app/sw.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Magpie service worker. Offline shell and share-target hand-off only.
// Journal data lives in IndexedDB, encrypted, and never touches a cache.

const VERSION = "magpie-v0.4.2";
const VERSION = "magpie-v0.4.3";
const SHARE_CACHE = "magpie-share";

const PRECACHE = [
Expand Down
1 change: 1 addition & 0 deletions fastlane/metadata/android/en-US/changelogs/403.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix: a very large attachment could crash the app. Attachments are capped at 50 MB now, and larger files are refused with a message instead.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "magpie",
"version": "0.4.2",
"version": "0.4.3",
"description": "A tamper-evident incident journal. Photos, notes, and files, hash-chained and encrypted on your device.",
"type": "module",
"private": true,
Expand Down
17 changes: 17 additions & 0 deletions test/e2e_app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,23 @@ async function main() {
const s1b = await c.send("Page.captureScreenshot", { format: "png" });
writeFileSync(path.join(SHOTS, "03-timeline-chain.png"), Buffer.from(s1b.result.data, "base64"));

// -------------------------------------- oversized attachment is refused
// Refused by File.size before any read, so a huge pick can't OOM the app.
// Regression: a ~500MB video pick used to crash it (found in review).
const bigFile = path.join(ROOT, "test", "fixtures", "toobig.bin");
writeFileSync(bigFile, Buffer.alloc(51 * 1024 * 1024));
await c.evalJs("document.getElementById('btn-add').click(); 'ok'");
await waitFor(() => c.evalJs("__magpieApi.state.screen === 'add'"), "add screen 4");
const { root: rootBig } = (await c.send("DOM.getDocument")).result;
const inputBig = (await c.send("DOM.querySelector", { nodeId: rootBig.nodeId, selector: "#attach-input" })).result;
await c.send("DOM.setFileInputFiles", { nodeId: inputBig.nodeId, files: [bigFile] });
await waitFor(() => c.evalJs("document.getElementById('toast').classList.contains('show')"), "oversized reject toast");
check("oversized attachment: refused, nothing staged", (await c.evalJs("document.getElementById('attach-name').hidden")) === true);
check("oversized attachment: toast names the 50 MB limit", (await c.evalJs("document.getElementById('toast').textContent")).includes("50 MB"));
await c.evalJs("document.getElementById('btn-add-cancel').click(); 'ok'");
await waitFor(() => c.evalJs("__magpieApi.state.screen === 'timeline'"), "timeline after refusal");
check("oversized attachment: added no entry", (await c.evalJs("document.querySelectorAll('#timeline li').length")) === 4);

// ---------------------------------------------------- in-memory search
await c.evalJs(`(() => { document.getElementById("search-timeline").value = "Broken window"; document.getElementById("search-timeline").dispatchEvent(new Event("input")); })()`);
check("search: matching entry stays visible", (await c.evalJs("document.querySelectorAll('#timeline li:not([hidden])').length")) === 1);
Expand Down
Loading