Compare commits

...
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 1b2142e6c4 fix(profile): fetch full profile for avatar decoration to avoid 404 spam
CI / Trigger Desktop Build (push) Successful in 19s
CI / Build & Quality Checks (push) Successful in 10m46s
useAvatarDecoration GET /profile/{user}/io.lotus.avatar_decoration returns 404 for
every user without a decoration (most users), which the browser logs as a failed
request — a console 404 per member. Fetch the whole profile (GET /profile/{user},
200 with all MSC4133 fields) and read the decoration field out of it instead. Same
negative-caching behavior; no functional change, just no 404 storm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:28:40 -04:00
jaredandClaude Opus 4.8 4236621c7a fix(build): serve favicon + all res/ icons at their referenced /public paths
The static-copy step landed the android icons at dist/public/android/ (stripBase
dropped the res/ segment), never copied public/res/apple/ or public/favicon.ico at
all, and the manifest pointed at /res/android/ — so every PWA icon, apple-touch
icon, og:image, and the favicon 404'd on the live server.

Copy all of public/res -> dist/public/res and public/favicon.ico -> dist/public/,
and point manifest.json icons at ./public/res/android/ — matching the /public/res
and /public/favicon.ico paths index.html already uses. Verified in dist/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:28:40 -04:00
jaredandClaude Opus 4.8 45afc9ba7e fix(crypto): request persistent storage on client init (KE-1 mitigation)
The IndexedDB crypto store is evictable while the localStorage session survives,
so the browser can drop it out from under a live login -> the device resurrects
with a blank key store and re-uploads a one-time key at an id Synapse already
holds -> a permanent '400 M_UNKNOWN: One time key ... already exists' upload
storm (and undecryptable to-device/media keys downstream).

initClient now calls navigator.storage.persist() before creating the crypto
store, so the origin's storage is marked persistent and won't be evicted.
Best-effort (granted by engagement/PWA-install, no prompt; denial is non-fatal).
Preventive only -- an already-diverged device still needs a clean re-login.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:18:32 -04:00
jaredandClaude Opus 4.8 fd87a27251 fix(embeds): stop building broken redd.it / i.redd.it Reddit embeds
embed.reddit.com only renders the /r/<sub>/comments/<id> path (verified against
reddit's embed widgets.js + curl: frame-ancestors * , no X-Frame-Options, so
iframing itself is fine). But getRedditPostEmbed built a bare
embed.reddit.com/comments/<id> for redd.it short links — which serves a 'not
found' page — and the i. host strip routed i.redd.it/*.jpg image links into the
same branch. Returning a (broken) URL also suppressed renderContent's og:url
fallback that would resolve the short link to its canonical /r/<sub>/... form.

Fix: getRedditPostEmbed returns null for any non-reddit.com host, so redd.it /
i.redd.it / v.redd.it fall through to the og:url fallback (working embed or a
normal preview card, never a blank 'not found' iframe). The reddit.com post path
is unchanged. Tests updated (22 pass).

Note: the live 'broken' symptom is mostly the deploy gap — live still runs the
old www.redditmedia.com embed code while the live CSP only allows
embed.reddit.com; deploying the current lotus branch (which emits
embed.reddit.com) resolves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:09:05 -04:00
6 changed files with 71 additions and 32 deletions
+12 -12
View File
@@ -11,58 +11,58 @@
"theme_color": "#980000",
"icons": [
{
"src": "./res/android/android-chrome-36x36.png",
"src": "./public/res/android/android-chrome-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-48x48.png",
"src": "./public/res/android/android-chrome-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-72x72.png",
"src": "./public/res/android/android-chrome-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-96x96.png",
"src": "./public/res/android/android-chrome-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-144x144.png",
"src": "./public/res/android/android-chrome-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-192x192.png",
"src": "./public/res/android/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-256x256.png",
"src": "./public/res/android/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-384x384.png",
"src": "./public/res/android/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "./res/android/android-chrome-512x512.png",
"src": "./public/res/android/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "./res/android/maskable-192x192.png",
"src": "./public/res/android/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./res/android/maskable-512x512.png",
"src": "./public/res/android/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
@@ -77,7 +77,7 @@
"url": "/",
"icons": [
{
"src": "res/android/android-chrome-96x96.png",
"src": "public/res/android/android-chrome-96x96.png",
"sizes": "96x96"
}
]
+8 -7
View File
@@ -29,7 +29,11 @@ function fetchDecoration(
const waiters: Array<(val: string | null) => void> = [];
pending.set(userId, waiters);
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
// Fetch the WHOLE profile, not the single `/{field}` sub-resource: an unset
// field returns 404, which the browser logs as a failed request — a console
// 404 for every user without a decoration. The full profile returns 200 with
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
cache.set(userId, val);
@@ -37,12 +41,9 @@ function fetchDecoration(
})
.catch((err: unknown) => {
const status = err instanceof MatrixError ? err.httpStatus : undefined;
// Definitive rejections — the field is unset (404) or the server won't
// serve it (400/403). This is the common case for FEDERATED users whose
// homeserver doesn't support extended profiles / rejects the field. Cache
// "no decoration" so we never refetch: otherwise every avatar mount
// re-requests and floods our homeserver with failing federated profile
// lookups (the 403/502 console storm + real HS load).
// Definitive rejections (404 unknown user / 403 can't view / 400) — cache
// "no decoration" so we never refetch a profile we can't read (otherwise
// every avatar mount re-floods our HS with failing federated lookups).
if (status === 404 || status === 403 || status === 400) {
cache.set(userId, null);
} else {
+6 -3
View File
@@ -216,15 +216,18 @@ test('Instagram: p / reel / tv → embed path', () => {
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
});
test('Reddit post embed → redditmedia', () => {
test('Reddit post embed → embed.reddit.com', () => {
assert.equal(
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
);
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
// redd.it short link → comments-only embed (no subreddit in the redirect)
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), 'https://embed.reddit.com/comments/1abc23/?ref_source=embed&ref=share&embed=true&theme=dark');
// redd.it short link / i.redd.it media host can't build the /r/<sub>/comments/<id>
// path embed.reddit.com requires (a bare /comments/<id> 404s) → null so the caller's
// og:url fallback resolves the canonical URL first.
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), null);
assert.equal(getRedditPostEmbed('https://i.redd.it/abcd1234.jpg'), null);
});
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
+8 -8
View File
@@ -344,8 +344,9 @@ export function getInstagramEmbed(url: string): string | null {
}
}
// --- Reddit (post embed via redditmedia — bypasses the homeserver's blocked
// preview fetch, which Reddit serves a bot-check "please wait" page to) ---
// --- Reddit (post embed via embed.reddit.com — reddit's own oEmbed widget host;
// www.redditmedia.com now 301s here. Bypasses the homeserver's blocked preview
// fetch, which Reddit serves a bot-check "please wait" page to) ---
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
@@ -353,15 +354,14 @@ export function getRedditPostEmbed(url: string): string | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
// redd.it/<id> short link → reddit.com/comments/<id> (no subreddit needed).
if (h === 'redd.it') {
const id = u.pathname.replace(/^\/+|\/+$/g, '').split('/')[0];
return id ? `https://embed.reddit.com/comments/${id}/${REDDIT_EMBED_QS}` : null;
}
// embed.reddit.com ONLY renders the /r/<sub>/comments/<id> path (verified against
// reddit's own embed widgets.js). A bare /comments/<id> — all we could build from a
// redd.it short link or an i./v.redd.it media host — serves a "not found" page, so
// return null for those and let the caller's og:url fallback resolve the canonical
// /r/<sub>/comments/<id> URL (from the redirect) before re-parsing into a real embed.
if (h !== 'reddit.com') return null;
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
if (!m) return null;
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
} catch {
return null;
+26
View File
@@ -12,7 +12,33 @@ import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
/**
* KE-1 mitigation. Ask the browser to make this origin's storage persistent so the
* IndexedDB **crypto store** isn't evicted from under a surviving `localStorage`
* session. When that happens the device "resurrects" with a blank key store and the
* client re-uploads a one-time key at an id Synapse already holds → a permanent
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
* install and shows no prompt; denial/absence is non-fatal.
*/
export const requestPersistentStorage = async (): Promise<boolean> => {
try {
if (!navigator.storage?.persist) return false;
if (await navigator.storage.persisted()) return true;
const granted = await navigator.storage.persist();
if (!granted) {
console.warn('Persistent storage not granted — the crypto store remains evictable (KE-1).');
}
return granted;
} catch {
return false;
}
};
export const initClient = async (session: Session): Promise<MatrixClient> => {
// Protect the crypto store from eviction before we create and write to it.
await requestPersistentStorage();
const indexedDBStore = new IndexedDBStore({
indexedDB: globalThis.indexedDB,
localStorage: globalThis.localStorage,
+11 -2
View File
@@ -38,9 +38,18 @@ const copyFiles = {
rename: { stripBase: true },
},
{
src: 'public/res/android',
// All of public/res/ (android + apple icons, logos, svg) → dist/public/res/,
// matching the /public/res/... paths used by index.html and manifest.json.
// (The old android-only target stripped `res/` and landed at /public/android/,
// so every icon 404'd — apple/ + favicon weren't copied at all.)
src: 'public/res',
dest: 'public/',
rename: { stripBase: 2 },
rename: { stripBase: 1 },
},
{
src: 'public/favicon.ico',
dest: 'public/',
rename: { stripBase: 1 },
},
{
src: 'public/locales',