From f34c4dc3357aaac3c9bc99987aafb35b3130f521 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 30 Jul 2026 13:13:14 -0700 Subject: [PATCH] Apps: normalize filetype associations to bare lowercase extensions (#3479) * Apps: normalize filetype associations to bare lowercase extensions Suggested-apps lookups match app_filetype_association rows against the bare lowercase extension ('docx'), but writes stored whatever the developer typed. Rows like '.docx' never matched, so those apps silently dropped out of Open With suggestions. AppStore now canonicalizes on write (trim, lowercase, strip leading dots, dedupe, drop empties) and tolerates the dotted legacy form on read: getAppsByFiletype normalizes the requested extension, matches both 'docx' and '.docx', and dedupes apps associated under both forms. Cache invalidation keys are normalized the same way. Existing dotted rows work without a data migration. * Update apps tests for extension canonicalization Adjust apps API tests to match current normalization behavior for `filetypeAssociations`: extension values are stored as lowercase bare extensions (e.g. `.txt` -> `txt`), while MIME types remain unchanged. Added inline comments in both test suites to document this expected remap. --- src/backend/drivers/apps/AppDriver.test.ts | 6 +- src/backend/stores/app/AppStore.js | 44 +++++++-- src/backend/stores/app/AppStore.test.js | 99 +++++++++++++++++++++ src/puter-js/test/apps.test.js | 4 +- src/puter-js/tests/api/suites/apps.suite.ts | 4 +- 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index c0cb619ff..48103f2a3 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -821,8 +821,9 @@ describe('AppDriver.create additional branches', () => { }), ); expect(Array.isArray(created.filetype_associations)).toBe(true); + // Dotted input is canonicalized to the bare lowercase extension. expect(created.filetype_associations).toEqual( - expect.arrayContaining(['.txt', '.md']), + expect.arrayContaining(['txt', 'md']), ); }); @@ -970,8 +971,9 @@ describe('AppDriver.update additional branches', () => { ? JSON.parse(updated.metadata) : updated.metadata; expect(meta).toEqual({ version: 2 }); + // Dotted input is canonicalized to the bare lowercase extension. expect(updated.filetype_associations).toEqual( - expect.arrayContaining(['.md', '.csv']), + expect.arrayContaining(['md', 'csv']), ); }); }); diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index bfcf9fbdc..10df3ac25 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -40,6 +40,14 @@ const LIST_CACHE_TRACKER_KEY = `${LIST_CACHE_KEY_PREFIX}:keys`; const LIST_CACHE_TTL_SECONDS = 15 * 60; const FILETYPE_CACHE_KEY_PREFIX = 'apps:by-filetype'; const FILETYPE_CACHE_TTL_SECONDS = 60; +// Filetype associations are matched against the bare lowercase extension +// (see SuggestedAppsService), but clients historically stored whatever the +// developer typed — `.docx` was common. Canonicalize on write and tolerate +// the dotted legacy form on read. +const normalizeFiletype = (type) => + typeof type === 'string' + ? type.trim().toLowerCase().replace(/^\.+/, '') + : ''; const APP_ID_PROPERTIES = ['id', 'uid', 'name']; // Old-name redirect window. After this many months an entry in // `old_app_names` is considered stale and is deleted on the next read @@ -652,7 +660,9 @@ export class AppStore extends PuterStore { // reads inside the TTL window hit redis. `setFiletypeAssociations` // invalidates the affected extension explicitly so changes show up // immediately. - const cacheKey = `${FILETYPE_CACHE_KEY_PREFIX}:${extension}`; + const ext = normalizeFiletype(extension); + if (!ext) return []; + const cacheKey = `${FILETYPE_CACHE_KEY_PREFIX}:${ext}`; try { const cached = await this.clients.redis.get(cacheKey); if (cached) { @@ -663,13 +673,22 @@ export class AppStore extends PuterStore { // Fall through to DB on any cache failure. } + // Rows written before writes were canonicalized may carry a leading + // dot (`.docx`); match both forms so they work without a migration. const rows = await this.clients.db.read( `SELECT a.* FROM \`apps\` a INNER JOIN \`app_filetype_association\` fa ON fa.\`app_id\` = a.\`id\` - WHERE fa.\`type\` = ?`, - [extension], + WHERE fa.\`type\` IN (?, ?)`, + [ext, `.${ext}`], ); - const apps = rows.map((r) => this.#normalizeRow(r)); + // An app associated under both forms joins to two rows. + const seenIds = new Set(); + const apps = []; + for (const row of rows) { + if (seenIds.has(row.id)) continue; + seenIds.add(row.id); + apps.push(this.#normalizeRow(row)); + } this.clients.redis .set( @@ -701,7 +720,16 @@ export class AppStore extends PuterStore { // Replace-all semantics. Capture the previous extension set so we // can drop their cached app lists in addition to the new ones. const previous = await this.getFiletypeAssociations(appId); - const newTypes = Array.isArray(types) ? types : []; + // Canonicalize before storing — readers match on the bare lowercase + // extension. Dedupe after normalizing: '.docx' and 'docx' in the + // same call are one association, not two rows. + const newTypes = [ + ...new Set( + (Array.isArray(types) ? types : []) + .map(normalizeFiletype) + .filter(Boolean), + ), + ]; // DELETE + multi-row INSERT in one transactional batch — partial // success would otherwise leave the row's filetype set in a state @@ -723,7 +751,11 @@ export class AppStore extends PuterStore { } await this.clients.db.batchWrite(entries); - const affected = new Set([...previous, ...newTypes]); + // Cache keys are always the canonical form — normalize `previous` + // too, since pre-existing rows may be stored dotted. + const affected = new Set( + [...previous.map(normalizeFiletype), ...newTypes].filter(Boolean), + ); if (affected.size === 0) return; const keys = [...affected].map( (t) => `${FILETYPE_CACHE_KEY_PREFIX}:${t}`, diff --git a/src/backend/stores/app/AppStore.test.js b/src/backend/stores/app/AppStore.test.js index 3bed62145..7f14a96b2 100644 --- a/src/backend/stores/app/AppStore.test.js +++ b/src/backend/stores/app/AppStore.test.js @@ -222,3 +222,102 @@ describe('AppStore batched lookups', () => { expect(found.get(a.id)?.title).toBe('Alpha'); }); }); + +describe('AppStore filetype associations', () => { + let server; + let appStore; + let db; + + beforeAll(async () => { + server = await setupTestServer(); + appStore = server.stores.app; + db = server.clients.db; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const createApp = async () => { + const name = `ft-${Math.random().toString(36).slice(2, 10)}`; + return appStore.create( + { + name, + title: 'Filetype Test', + index_url: `https://${name}.example.com/`, + }, + { ownerUserId: 1 }, + ); + }; + + // Unique per test — getAppsByFiletype caches per extension in redis, so + // sharing an extension across tests would serve stale results. + const freshExt = () => `ext${Math.random().toString(36).slice(2, 10)}`; + + const insertRawAssociation = (appId, type) => + db.write( + 'INSERT INTO `app_filetype_association` (`app_id`, `type`) VALUES (?, ?)', + [appId, type], + ); + + it('canonicalizes on write: trims, lowercases, strips leading dots, dedupes', async () => { + const app = await createApp(); + + await appStore.setFiletypeAssociations(app.id, [ + ' .DocX ', + 'docx', + '.doc', + ]); + + const stored = await appStore.getFiletypeAssociations(app.id); + expect(stored.sort()).toEqual(['doc', 'docx']); + }); + + it('drops entries that normalize to nothing', async () => { + const app = await createApp(); + + await appStore.setFiletypeAssociations(app.id, ['.', ' ', 'txt']); + + expect(await appStore.getFiletypeAssociations(app.id)).toEqual([ + 'txt', + ]); + }); + + it('getAppsByFiletype matches legacy rows stored with a leading dot', async () => { + const app = await createApp(); + const ext = freshExt(); + // Pre-normalization rows were written verbatim from client input. + await insertRawAssociation(app.id, `.${ext}`); + + const apps = await appStore.getAppsByFiletype(ext); + + expect(apps.map((a) => a.id)).toContain(app.id); + }); + + it('getAppsByFiletype returns one entry for an app associated under both forms', async () => { + const app = await createApp(); + const ext = freshExt(); + await insertRawAssociation(app.id, ext); + await insertRawAssociation(app.id, `.${ext}`); + + const apps = await appStore.getAppsByFiletype(ext); + + expect(apps.filter((a) => a.id === app.id)).toHaveLength(1); + }); + + it('getAppsByFiletype normalizes the requested extension', async () => { + const app = await createApp(); + const ext = freshExt(); + await appStore.setFiletypeAssociations(app.id, [ext]); + + const apps = await appStore.getAppsByFiletype(`.${ext.toUpperCase()}`); + + expect(apps.map((a) => a.id)).toContain(app.id); + }); + + it('getAppsByFiletype returns [] when the extension normalizes to nothing', async () => { + expect(await appStore.getAppsByFiletype('')).toEqual([]); + expect(await appStore.getAppsByFiletype('.')).toEqual([]); + expect(await appStore.getAppsByFiletype(null)).toEqual([]); + }); +}); diff --git a/src/puter-js/test/apps.test.js b/src/puter-js/test/apps.test.js index 5846e4d75..6ab20ef2d 100644 --- a/src/puter-js/test/apps.test.js +++ b/src/puter-js/test/apps.test.js @@ -37,7 +37,9 @@ window.appsTests = [ }); const fetched = await puter.apps.get(name); assert(Boolean(fetched.maximize_on_start) === true, "maximize_on_start not stored"); - assert(JSON.stringify(fetched.filetype_associations) === JSON.stringify(['.txt', 'image/png']), "filetype_associations not stored"); + // Extensions are canonicalized to the bare lowercase form on + // write ('.txt' → 'txt'); MIME types pass through unchanged. + assert(JSON.stringify(fetched.filetype_associations) === JSON.stringify(['txt', 'image/png']), "filetype_associations not stored"); pass("testCreateOptionsRemap passed"); } catch (error) { fail("testCreateOptionsRemap failed:", error); diff --git a/src/puter-js/tests/api/suites/apps.suite.ts b/src/puter-js/tests/api/suites/apps.suite.ts index 8d9c55ed2..ea01e30fd 100644 --- a/src/puter-js/tests/api/suites/apps.suite.ts +++ b/src/puter-js/tests/api/suites/apps.suite.ts @@ -222,7 +222,9 @@ export default suite('apps', { maximizeOnStart: true, }); const fetched = await t.puter.apps.get('apps-suite-remap'); - t.assert.deepEqual(fetched.filetype_associations, ['.txt', 'image/png']); + // Extensions are canonicalized to the bare lowercase form on write + // ('.txt' → 'txt'); MIME-type associations pass through unchanged. + t.assert.deepEqual(fetched.filetype_associations, ['txt', 'image/png']); t.assert.equal(Boolean(fetched.maximize_on_start), true); }, });