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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Fixed

- Create AI link no longer aborts with `Invalid file path` when an image is AVIF, BMP, HEIC or
SVG. AVIF uploads under its own extension; media of a type the share Worker rejects is left
out and the shared copy lists it as missing, as it does for raw video (#80).

## [1.6.9] - 2026-10-07

Fixes from the 2026-10-07 full-repo review.
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ module.exports = [
ignores: [
'node_modules/**',
'dist/**',
// Agent worktrees the Claude desktop app leaves under .claude/ carry their own dist/.
'.claude/**',
'_scratch/**',
'SourceCapsule/**',
'share-worker/.wrangler/**',
'*.export.html',
Expand Down
22 changes: 21 additions & 1 deletion sourcecapsule.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,8 @@
return 'gif';
case 'image/webp':
return 'webp';
case 'image/avif':
return 'avif';
case 'image/svg+xml':
return 'svg';
case 'video/mp4':
Expand Down Expand Up @@ -5656,6 +5658,12 @@ figure video{display:block;width:100%;height:auto;border-radius:14px;border:1px
return { ...model, blocks: mapBlocks(model.blocks) };
}

// Mirror of the Worker's `validFilePath` rule for media uploads. One rejected PUT aborts the
// whole share, so a name the Worker would answer 400 to must never reach the upload loop;
// that media is left out and the shared copy lists it as missing, like raw video (#80).
const SHARE_MEDIA_NAME =
/^media\/[A-Za-z0-9][A-Za-z0-9._-]{0,179}\.(?:jpe?g|png|gif|webp|avif)$/i;

async function createShareLink(model, debugJson, expiryDays, onProgress) {
const apiBase = getPrefs().shareApiBase.replace(/\/$/, '');
// Share can be invoked from the receipt after a long/lazy Article capture. Do one
Expand All @@ -5677,7 +5685,18 @@ figure video{display:block;width:100%;height:auto;border-radius:14px;border:1px
});
bundle = collectBundleMediaFiles(model);
}
const { files, pathById } = bundle;
const { pathById } = bundle;
const files = bundle.files.filter((file) => SHARE_MEDIA_NAME.test(file.name));
const mediaFilesSkipped = bundle.files.length - files.length;
if (mediaFilesSkipped) {
const kept = new Set(files.map((file) => file.name));
pathById.forEach((name, mediaId) => {
if (!kept.has(name)) pathById.delete(mediaId);
});
warn(
`${mediaFilesSkipped} media file(s) have a type the share service does not accept; the shared copy lists them as missing.`
);
}
const mediaBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0);
if (mediaBytes > CONFIG.share.maxBytes) {
throw new Error(
Expand Down Expand Up @@ -5762,6 +5781,7 @@ figure video{display:block;width:100%;height:auto;border-radius:14px;border:1px
headers: { Authorization: `Bearer ${created.uploadToken}` },
});
created.mediaFilesUploaded = files.length;
created.mediaFilesSkipped = mediaFilesSkipped;
created.mediaBytesUploaded = mediaBytes;
// The copy a recipient actually sees. Raw video is never uploaded, so a video whose
// poster failed is missing there even though the local model has its bytes. Callers
Expand Down
75 changes: 75 additions & 0 deletions test/dom.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5472,6 +5472,81 @@ await checkAsync(
}
);

await checkAsync(
'share leaves out media whose file type the Worker rejects instead of aborting (#80)',
async () => {
// The Worker accepts only jpe?g|png|gif|webp|avif under media/. A BMP used to become
// media/<id>.bin, that PUT answered 400, and the share aborted after three uploads.
const BMP =
'data:image/bmp;base64,' +
Buffer.alloc(16, 0).fill(Buffer.from('BM'), 0, 2).toString('base64');
const AVIF =
'data:image/avif;base64,' +
Buffer.concat([
Buffer.from([0, 0, 0, 28]),
Buffer.from('ftypavif'),
Buffer.alloc(8),
]).toString('base64');
const model = {
type: 'post',
title: 'Share skips bin',
heading: 'Share skips bin',
sourceUrl: STATUS_URL,
author: { name: 'Vega Hao', handle: '@Vegahao' },
blocks: [
{ kind: 'paragraph', html: 'Hello' },
{ kind: 'image', url: 'https://pbs.twimg.com/media/Bmp1.jpg', dataUri: BMP },
{ kind: 'image', url: 'https://pbs.twimg.com/media/Avif1.jpg', dataUri: AVIF },
],
};
const puts = [];
global.GM_xmlhttpRequest = (options) => {
if (options.method === 'POST' && options.url.endsWith('/api/capsules')) {
options.onload({
status: 200,
responseText: JSON.stringify({
uploadUrl: 'https://share.test/api/capsules/abc/files',
uploadToken: 't',
finalizeUrl: 'https://share.test/api/capsules/abc/finalize',
viewUrl: 'https://share.test/c/abc',
}),
});
return;
}
if (options.method === 'PUT') {
const name = decodeURIComponent(options.url.split('/files/')[1]);
puts.push(name);
if (name.startsWith('media/') && !/[.](?:jpe?g|png|gif|webp|avif)$/i.test(name)) {
options.onload({
status: 400,
responseText: JSON.stringify({ error: 'Invalid file path.' }),
});
return;
}
}
options.onload({ status: 200, responseText: '' });
};
let created;
try {
created = await engine.createShareLink(model, '', 7);
} finally {
delete global.GM_xmlhttpRequest;
}
assert.equal(engine.mimeToExt('image/avif'), 'avif');
const media = puts.filter((name) => name.startsWith('media/'));
assert.equal(media.length, 1, 'only the accepted image was uploaded');
assert.match(media[0], /^media[/]image-[0-9]+[.]avif$/, 'AVIF uploads under its own extension');
assert.equal(created.mediaFilesUploaded, 1);
assert.equal(created.mediaFilesSkipped, 1);
assert.equal(
engine.assessExportCompleteness(created.sharedModel).verdict,
'incomplete',
'the shared copy reports the left-out image as missing'
);
assert.match(model.blocks[1].dataUri, /^data:image[/]bmp/, 'the live model keeps its bytes');
}
);

await checkAsync(
'receipt share keeps the saved note and tags, re-saves into the original folder, and survives a library error',
async () => {
Expand Down
Loading