diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 2d849ec1d..548739514 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -170,6 +170,9 @@ const isAppDataRootPath = (path: string): boolean => { ); }; +/** Ends a subtree removal whose `proceed` check said to stop. */ +class RemovalStopped extends Error {} + const isNoSuchKeyError = (err: unknown): boolean => { if (!err || typeof err !== 'object') return false; const e = err as { name?: unknown; Code?: unknown }; @@ -311,6 +314,10 @@ export class FSService extends PuterService { override onServerStart(): void { this.#registerPermissionRules(); + this.clients.event.on('app.uninstalled', async (_key, data) => { + if (!data?.deleteData) return; + void this.#removeUninstalledAppData(data.userId, data.appUid); + }); } /** @@ -4565,27 +4572,111 @@ export class FSService extends PuterService { /** * Delete a directory's contents a page at a time, leaves first, so neither * memory nor a single statement grows with the subtree: a page's files go - * as it is read, a directory once everything under it has. + * as it is read, a directory once everything under it has. With `proceed`, + * each batch is deleted only while it answers true; false if it stopped. */ async #removeDescendants( root: FSEntry, survivors: () => Promise, - ): Promise { + proceed?: () => Promise, + ): Promise { const removeEntries = async (entries: FSEntry[]) => { if (entries.length === 0) return; + if (proceed && !(await proceed())) throw new RemovalStopped(); // S3 objects are batched per bucket+region. await this.#removeDescendantsStorage(entries); await this.stores.fsEntry.deleteEntries(entries); this.#emitRemoveEvents(entries, survivors); }; - await this.#walkSubtree( - root, - async (page) => { - await removeEntries(page.filter((entry) => !entry.isDir)); - return page.filter((entry) => entry.isDir); - }, - removeEntries, - ); + try { + await this.#walkSubtree( + root, + async (page) => { + await removeEntries(page.filter((entry) => !entry.isDir)); + return page.filter((entry) => entry.isDir); + }, + removeEntries, + ); + } catch (err) { + if (err instanceof RemovalStopped) return false; + throw err; + } + return true; + } + + /** + * Delete `//AppData/` once the user removed the app with its + * data. Stops if the user authorizes the app again, so files written after + * that are kept. A failure is logged, not retried. + */ + async #removeUninstalledAppData( + userId: number, + appUid: string, + ): Promise { + if (!userId || !appUid || ['.', '..'].includes(appUid)) return; + if (appUid.includes('/')) return; + try { + const username = (await this.stores.user.getById(userId))?.username; + if (!username) return; + const path = `/${username}/AppData/${appUid}`; + const root = await this.stores.fsEntry.getEntryByPath(path, { + lineage: false, + skipCache: true, + }); + // The path column's collation also matches other spellings. + if (!root?.isDir || root.path !== path || root.userId !== userId) + return; + if (await this.#subtreeHostsContent(root)) { + console.warn( + `[fs] kept uninstalled app data at ${path}: it hosts a site or worker`, + ); + return; + } + + const appId = (await this.stores.app.getByUid(appUid))?.id; + const stillUninstalled = async () => + typeof appId !== 'number' || + ( + await this.stores.permission.listUserAppPermsFromPrimary( + userId, + appId, + ) + ).length === 0; + if (!(await stillUninstalled())) return; + + const survivors = this.#memoizedChain(pathPosix.dirname(path)); + const emptied = await this.#removeDescendants( + root, + survivors, + stillUninstalled, + ); + if (!emptied || !(await stillUninstalled())) return; + await this.stores.fsEntry.deleteEntry(root); + this.#emitRemoveEvents([root], survivors); + } catch (err) { + console.warn('[fs] uninstalled app data removal failed', err); + } + } + + /** Whether a site or worker is rooted at `root` or anywhere below it. */ + async #subtreeHostsContent(root: FSEntry): Promise { + const hosts = (entry: FSEntry) => + entry.subdomains.length > 0 || entry.workers.length > 0; + if (hosts(root)) return true; + try { + await this.#walkSubtree( + root, + async (page) => { + if (page.some(hosts)) throw new RemovalStopped(); + return page.filter((entry) => entry.isDir); + }, + async () => {}, + ); + } catch (err) { + if (err instanceof RemovalStopped) return true; + throw err; + } + return false; } /** diff --git a/src/backend/services/fs/FSService.uninstallAppData.test.ts b/src/backend/services/fs/FSService.uninstallAppData.test.ts new file mode 100644 index 000000000..4b04413c0 --- /dev/null +++ b/src/backend/services/fs/FSService.uninstallAppData.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; +import { FSService } from './FSService.js'; + +/** Removing an app "with its data" takes its AppData folder too. */ +describe('uninstalling an app with its data', () => { + let env: PuterTestEnv; + const pageSize = FSService.SUBTREE_PAGE_SIZE; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + afterEach(() => { + FSService.SUBTREE_PAGE_SIZE = pageSize; + vi.restoreAllMocks(); + }); + + const post = (path: string, token: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const exists = async (path: string) => + (await env.server.stores.fsEntry.getEntryByPath(path, { + skipCache: true, + })) !== null; + + const launch = async (origin: string) => { + const launched = await post( + '/auth/get-user-app-token', + env.users.user.token, + { origin }, + ); + expect(launched.status).toBe(200); + return (await launched.json()) as { token: string; app_uid: string }; + }; + + /** + * An app with `count` files in its AppData, plus a folder whose name + * extends its uid. + */ + const installWithFiles = async (count: number) => { + const { username, token } = env.users.user; + const origin = `https://uninstall-${Math.random().toString(36).slice(2, 10)}.example.com`; + const app = await launch(origin); + expect( + ( + await post('/auth/request-app-root-dir', app.token, { + app_uid: app.app_uid, + }) + ).status, + ).toBe(200); + const root = `/${username}/AppData/${app.app_uid}`; + const files: string[] = []; + for (let i = 0; i < count; i++) { + const path = `${root}/notes-${i}.txt`; + const written = await post('/fs/write', app.token, { + fileMetadata: { path, size: 1, contentType: 'text/plain' }, + fileContent: 'x', + }); + expect(written.status).toBe(200); + files.push(path); + } + const lookalike = `${root}-keep`; + expect( + (await post('/fs/mkdir', token, { path: lookalike })).status, + ).toBe(200); + return { app, origin, root, files, lookalike }; + }; + + const uninstall = (appUid: string, deleteData: boolean) => + post('/auth/revoke-user-app', env.users.user.token, { + app_uid: appUid, + permission: '*', + deleteData, + }); + + it("deletes exactly the app's AppData folder", async () => { + const { app, root, files, lookalike } = await installWithFiles(3); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(async () => expect(await exists(root)).toBe(false)); + for (const file of files) expect(await exists(file)).toBe(false); + expect(await exists(lookalike)).toBe(true); + }); + + /** A site rooted at `dir`, serving an index.html written by the app. */ + const hostSite = async (appToken: string, dir: string) => { + const written = await post('/fs/write', appToken, { + fileMetadata: { + path: `${dir}/index.html`, + size: 6, + contentType: 'text/html', + }, + fileContent: 'hosted', + }); + expect(written.status).toBe(200); + const entry = (await env.server.stores.fsEntry.getEntryByPath(dir, { + skipCache: true, + }))!; + const subdomain = `uninstall-${Math.random().toString(36).slice(2, 10)}`; + await env.server.stores.subdomain.create({ + userId: entry.userId, + subdomain, + rootDirId: entry.id, + }); + const port = new URL(env.origin).port; + const serve = () => + fetch( + `http://${subdomain}.site.puter.localhost:${port}/index.html`, + ); + const served = await serve(); + expect(served.status).toBe(200); + expect(await served.text()).toBe('hosted'); + return serve; + }; + + it.each([ + ['a folder inside it', '/www/public'], + ['the folder itself', ''], + ])('keeps AppData that hosts a site at %s', async (_case, sub) => { + const { app, root, files } = await installWithFiles(2); + const siteDir = `${root}${sub}`; + if (sub) { + const made = await post('/fs/mkdir', app.token, { + path: siteDir, + create_missing_parents: true, + }); + expect(made.status).toBe(200); + } + const serve = await hostSite(app.token, siteDir); + const warn = vi.spyOn(console, 'warn'); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`kept uninstalled app data at ${root}`), + ), + ); + const served = await serve(); + expect(served.status).toBe(200); + expect(await served.text()).toBe('hosted'); + for (const file of files) expect(await exists(file)).toBe(true); + }); + + it('keeps the folder without deleteData', async () => { + const { app, root, files } = await installWithFiles(1); + const checks = vi.spyOn( + env.server.stores.fsEntry, + 'deleteEntries', + ); + + expect((await uninstall(app.app_uid, false)).status).toBe(200); + + expect(checks).not.toHaveBeenCalled(); + expect(await exists(root)).toBe(true); + expect(await exists(files[0]!)).toBe(true); + }); + + it('stops once the user authorizes the app again', async () => { + FSService.SUBTREE_PAGE_SIZE = 1; + const { app, origin, root, files } = await installWithFiles(4); + const { fsEntry, permission } = env.server.stores; + + // The app is opened again right after its first file is deleted. + const deleteEntries = fsEntry.deleteEntries.bind(fsEntry); + let reopened = false; + vi.spyOn(fsEntry, 'deleteEntries').mockImplementation( + async (entries) => { + await deleteEntries(entries); + if (!reopened) { + reopened = true; + await launch(origin); + } + }, + ); + const listGrants = + permission.listUserAppPermsFromPrimary.bind(permission); + let sawGrant = false; + vi.spyOn(permission, 'listUserAppPermsFromPrimary').mockImplementation( + async (userId, appId) => { + const rows = await listGrants(userId, appId); + if (rows.length > 0) sawGrant = true; + return rows; + }, + ); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(() => expect(sawGrant).toBe(true)); + const left = (await Promise.all(files.map(exists))).filter(Boolean); + expect(left).toHaveLength(files.length - 1); + expect(await exists(root)).toBe(true); + }); +});