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.
This commit is contained in:
Nariman Jelveh
2026-07-30 13:13:14 -07:00
committed by GitHub
parent 5a157197b6
commit f34c4dc335
5 changed files with 147 additions and 10 deletions
+4 -2
View File
@@ -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']),
);
});
});
+38 -6
View File
@@ -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}`,
+99
View File
@@ -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([]);
});
});
+3 -1
View File
@@ -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);
+3 -1
View File
@@ -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);
},
});