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); }, });