diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index c680230c5..5fcfd892e 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -2920,6 +2920,223 @@ describe('FSService restructuring a shared tree', () => { }); }); +describe('FSService ownership in a shared tree', () => { + let owner: TestUser; + let holder: TestUser; + let shared: FSEntry; + + const asHolder = (run: () => Promise): Promise => + runWithContext({ actor: holder.actor }, run); + + beforeAll(async () => { + owner = await makeUser(); + holder = await makeUser(); + shared = await fs.mkdir(owner.userId, { + path: `${owner.home}/Documents/Team`, + }); + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => fs.getAncestorChain(shared.path), + }, + 'write', + ); + }); + + it('gives a folder the recipient creates to the folder owner', async () => { + const created = await asHolder(() => + fs.mkdir(holder.userId, { path: `${shared.path}/from-holder` }), + ); + + expect(created.userId).toBe(owner.userId); + }); + + it('gives a file the recipient writes to the folder owner', async () => { + const created = await asHolder(() => + fs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/note.txt`, + size: 1, + contentType: 'text/plain', + }, + fileContent: 'x', + }), + ); + + expect(created.fsEntry.userId).toBe(owner.userId); + }); + + it('gives a file the recipient touches to the folder owner', async () => { + const created = await asHolder(() => + fs.touch(holder.userId, { path: `${shared.path}/touched.txt` }), + ); + + expect(created.userId).toBe(owner.userId); + }); + + it('gives intermediate directories to the folder owner', async () => { + await asHolder(() => + fs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/a/b/deep.txt`, + size: 1, + contentType: 'text/plain', + createMissingParents: true, + }, + fileContent: 'x', + }), + ); + + const a = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/a`, + ); + const b = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/a/b`, + ); + expect(a?.userId).toBe(owner.userId); + expect(b?.userId).toBe(owner.userId); + }); + + it('gives a copy the recipient makes to the folder owner', async () => { + const source = await writeFile( + holder, + `${holder.home}/Documents/mine.txt`, + 'x', + ); + + const copy = await asHolder(() => + fs.copy(holder.userId, { + source, + destinationParent: shared, + newName: 'copied.txt', + }), + ); + + expect(copy.userId).toBe(owner.userId); + // The original stays where it was, with its own owner. + expect( + (await server.stores.fsEntry.getEntryByPath(source.path))?.userId, + ).toBe(holder.userId); + }); + + it('hands over an entry the recipient moves in, subtree and all', async () => { + const dir = await fs.mkdir(holder.userId, { + path: `${holder.home}/Documents/handover`, + }); + await writeFile( + holder, + `${holder.home}/Documents/handover/inside.txt`, + 'x', + ); + + const moved = await asHolder(() => + fs.move(holder.userId, { source: dir, destinationParent: shared }), + ); + + expect(moved.userId).toBe(owner.userId); + const inside = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/handover/inside.txt`, + { skipCache: true }, + ); + expect(inside?.userId).toBe(owner.userId); + }); + +}); + +describe('FSService storage allowance in a shared tree', () => { + let limitedServer: PuterServer; + let limitedFs: FSService; + + beforeAll(async () => { + limitedServer = await setupTestServer({ + is_storage_limited: true, + } as never); + limitedFs = limitedServer.services.fs as unknown as FSService; + }); + + afterAll(async () => { + await limitedServer?.shutdown(); + }); + + const quotaUser = async (freeStorage: number) => { + const username = `fsqs-${Math.random().toString(36).slice(2, 10)}`; + const created = await limitedServer.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: freeStorage, + requires_email_confirmation: false, + }); + await generateDefaultFsentries( + limitedServer.clients.db, + limitedServer.stores.user, + created, + ); + const refreshed = (await limitedServer.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + home: `/${username}`, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + } as Actor, + }; + }; + + it('charges the folder owner, so their limit stops the writer', async () => { + const owner = await quotaUser(64); + const holder = await quotaUser(10 * 1024 * 1024); + const shared = await limitedFs.mkdir(owner.userId, { + path: `${owner.home}/Documents/Tight`, + }); + await limitedServer.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => + limitedFs.getAncestorChain(shared.path), + }, + 'write', + ); + + const body = 'x'.repeat(4096); + const error = await caught(() => + runWithContext({ actor: holder.actor }, () => + limitedFs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/big.txt`, + size: body.length, + }, + fileContent: body, + }), + ), + ); + + expect(error.statusCode).toBe(413); + expect(error.legacyCode).toBe('storage_limit_reached'); + // The same write into the writer's own roomy home still goes through, + // so it was the owner's limit that stopped it, not a blanket refusal. + await runWithContext({ actor: holder.actor }, () => + limitedFs.write(holder.userId, { + fileMetadata: { + path: `${holder.home}/Documents/big.txt`, + size: body.length, + }, + fileContent: body, + }), + ); + }); +}); + describe('FSService access checks', () => { let owner: TestUser; let stranger: TestUser; diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index aee09ac9c..aad1987b5 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -743,6 +743,17 @@ export class FSService extends PuterService { return Math.max(allowanceMax, storageAllowanceMaxOverride); } + /** + * Whose allowance a write to `path` draws on — the owner of the tree it + * lands in, which is not the writer when the folder was shared with them. + */ + async #storageOwnerOf(path: string, actingUserId: number): Promise { + const home = `/${this.#normalizePath(path).split('/')[1] ?? ''}`; + if (home === '/') return actingUserId; + const entry = await this.stores.fsEntry.getEntryByPath(home); + return entry?.userId ?? actingUserId; + } + async #assertStorageAllowance( userId: number, incomingSize: number, @@ -1409,23 +1420,36 @@ export class FSService extends PuterService { } } - const sizeChanges = preparedBatch.items.map((item) => { + // One batch can straddle two trees, and each owner pays for its own. + const sizeChangesByOwner = new Map< + number, + Array<{ incomingSize: number; existingSize: number }> + >(); + for (const item of preparedBatch.items) { const uploadedItem = uploadedItemMap.get(item.index); - return { + const owner = await this.#storageOwnerOf( + item.normalizedInput.path, + preparedBatch.userId, + ); + const sizeChanges = sizeChangesByOwner.get(owner) ?? []; + sizeChanges.push({ incomingSize: uploadedItem ? uploadedItem.uploadedSize : item.normalizedInput.size, existingSize: item.existingEntry?.size ?? 0, - }; - }); + }); + sizeChangesByOwner.set(owner, sizeChanges); + } const storageAllowanceMax = storageAllowanceMaxOverride ?? preparedBatch.storageAllowanceMax; - await this.#assertStorageAllowanceForBatch( - preparedBatch.userId, - sizeChanges, - storageAllowanceMax, - ); + for (const [owner, sizeChanges] of sizeChangesByOwner) { + await this.#assertStorageAllowanceForBatch( + owner, + sizeChanges, + storageAllowanceMax, + ); + } } async uploadPreparedBatchItem( @@ -1622,11 +1646,14 @@ export class FSService extends PuterService { const parentPath = pathPosix.dirname(normalizedInput.path); const [, { parentEntries, createdDirectoryEntries }] = await Promise.all([ - this.#assertStorageAllowance( - userId, - normalizedInput.size, - existingSize, - storageAllowanceMax, + this.#storageOwnerOf(normalizedInput.path, userId).then( + (owner) => + this.#assertStorageAllowance( + owner, + normalizedInput.size, + existingSize, + storageAllowanceMax, + ), ), this.stores.fsEntry.resolveParentDirectoriesBatchWithCreated( userId, @@ -1846,15 +1873,21 @@ export class FSService extends PuterService { }; }); - const allowanceChecks: Array<{ - incomingSize: number; - existingSize: number; - }> = []; + const allowanceChecksByOwner = new Map< + number, + Array<{ incomingSize: number; existingSize: number }> + >(); for (const item of resolvedFileItems) { - allowanceChecks.push({ + const owner = await this.#storageOwnerOf( + item.normalizedInput.path, + userId, + ); + const checks = allowanceChecksByOwner.get(owner) ?? []; + checks.push({ incomingSize: item.normalizedInput.size, existingSize: item.existingEntry?.size ?? 0, }); + allowanceChecksByOwner.set(owner, checks); } const [ , @@ -1863,10 +1896,14 @@ export class FSService extends PuterService { createdDirectoryEntries: createdParentDirectoryEntries, }, ] = await Promise.all([ - this.#assertStorageAllowanceForBatch( - userId, - allowanceChecks, - storageAllowanceMax, + Promise.all( + [...allowanceChecksByOwner].map(([owner, checks]) => + this.#assertStorageAllowanceForBatch( + owner, + checks, + storageAllowanceMax, + ), + ), ), this.stores.fsEntry.resolveParentDirectoriesBatchWithCreated( userId, @@ -2690,8 +2727,12 @@ export class FSService extends PuterService { normalizedInput.thumbnail = null; const existingSize = existingEntry?.size ?? 0; - await this.#assertStorageAllowance( + const storageOwner = await this.#storageOwnerOf( + normalizedInput.path, userId, + ); + await this.#assertStorageAllowance( + storageOwner, normalizedInput.size, existingSize, storageAllowanceMax, @@ -2728,7 +2769,7 @@ export class FSService extends PuterService { } if (uploadedSize > normalizedInput.size) { await this.#assertStorageAllowance( - userId, + storageOwner, uploadedSize, existingSize, storageAllowanceMax, @@ -3163,7 +3204,6 @@ export class FSService extends PuterService { let created: FSEntry; try { created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent, name, kind: 'directory', @@ -3235,7 +3275,6 @@ export class FSService extends PuterService { }); } const created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent, name, kind: 'empty-file', @@ -3329,7 +3368,6 @@ export class FSService extends PuterService { } } const created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent: input.parent, name, kind: 'shortcut', @@ -3341,13 +3379,6 @@ export class FSService extends PuterService { // -- Mutation: remove / move / copy --------------------------------- - /** - * Remove an entry. For directories, descendants are walked and removed - * (both DB rows and S3 objects). Emits `fs.remove.node` per file so the - * thumbnail extension (and any other listener) can clean up side state. - * - * Does NOT enforce ACL — caller (controller) performs the `write` check. - */ /** * Delete, move, and rename all ask ACL for `fs:write`, which cannot tell * them apart from an ordinary write — so the delete class is enforced here @@ -3410,6 +3441,14 @@ export class FSService extends PuterService { ); } + /** + * Remove an entry. For directories, descendants are walked and removed + * (both DB rows and S3 objects). Emits `fs.remove.node` per file so the + * thumbnail extension (and any other listener) can clean up side state. + * + * The caller checks `write` on the entry; the parent check that governs + * restructuring is enforced here. + */ async remove( userId: number, input: { @@ -3722,9 +3761,20 @@ export class FSService extends PuterService { this.#stripReservedMetadataKeys(input.newMetadata), ); + // Moving your entry into another tree hands it over, bytes included, + // so they have to fit the new owner's allowance. + const newOwnerId = destinationParent.userId; + if (newOwnerId !== source.userId) { + await this.#assertStorageAllowance( + newOwnerId, + await this.#entryStorageSize(source), + ); + } + const updated = await this.stores.fsEntry.updateEntry(source.uuid, { name, path: finalPath, + userId: newOwnerId, parentId: destinationParent.id, parentUid: destinationParent.uuid, ...(metadataPatch !== undefined ? { metadata: metadataPatch } : {}), @@ -3735,6 +3785,7 @@ export class FSService extends PuterService { source.userId, source.path, finalPath, + newOwnerId, ); } @@ -3802,7 +3853,7 @@ export class FSService extends PuterService { // the allowance as writing them. Check before the overwrite below // removes anything, and credit what that removal frees. await this.#assertStorageAllowance( - userId, + destinationParent.userId, await this.#entryStorageSize(source), collision && input.overwrite ? await this.#entryStorageSize(collision) @@ -3851,7 +3902,6 @@ export class FSService extends PuterService { // 2) Walk descendants; for each, compute new path by swapping prefix // 3) Create a new row (files copy S3 object; dirs just insert) const newRoot = await this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name, kind: 'directory', @@ -3882,7 +3932,6 @@ export class FSService extends PuterService { } const copied = descendant.isDir ? await this.stores.fsEntry.createNonFileEntry({ - userId, parent: newParent, name: descendant.name, kind: 'directory', @@ -3918,7 +3967,6 @@ export class FSService extends PuterService { ): Promise { if (source.isSymlink) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'symlink', @@ -3929,7 +3977,6 @@ export class FSService extends PuterService { } if (source.isShortcut) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'shortcut', @@ -3945,7 +3992,6 @@ export class FSService extends PuterService { // the source as another empty-file entry instead of touching S3. if (hasNoBackingS3Object(source)) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'empty-file', diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index d56a625ae..57d987538 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -697,7 +697,7 @@ export class FSEntryStore extends PuterStore { ); insertRows.push( expectedUuid, - userId, + parentEntry ? parentEntry.userId : userId, parentEntry ? parentEntry.id : null, parentEntry ? parentEntry.uuid : null, pathPosix.basename(dirPath), @@ -848,7 +848,7 @@ export class FSEntryStore extends PuterStore { ) VALUES (?, ?, ?, ?, ?, ?, ${trueLiteral}, ?, ?, ?, ${falseLiteral}, 0)${this.clients.db.insertIgnoreSuffix()}`, [ uuidv4(), - userId, + parentEntry ? parentEntry.userId : userId, parentEntry ? parentEntry.id : null, parentEntry ? parentEntry.uuid : null, dirName, @@ -1875,7 +1875,7 @@ export class FSEntryStore extends PuterStore { entry.input.uuid, entry.bucket, entry.bucketRegion, - userId, + parentEntry.userId, parentEntry.id, parentEntry.uuid, entry.input.associatedAppId ?? null, @@ -1948,9 +1948,11 @@ export class FSEntryStore extends PuterStore { const placeholders = insertUuidChunk .map(() => '?') .join(', '); + // By uuid alone — the rows just written belong to the + // parent's owner, not necessarily the acting user. const rows = (await this.clients.db.tryHardRead( - `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE user_id = ? AND uuid IN (${placeholders})`, - [userId, ...insertUuidChunk], + `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid IN (${placeholders})`, + insertUuidChunk, )) as unknown as FSEntryRow[]; const insertedEntries = rows.map((row) => @@ -2276,9 +2278,11 @@ export class FSEntryStore extends PuterStore { * * Returns the inserted entry with a refreshed row read. Throws 409 on a * unique-key collision (caller should pre-check and dedupe). + * + * The row belongs to whoever owns `parent`, not to whoever created it, so a + * shared subtree keeps one owner throughout. */ async createNonFileEntry(input: { - userId: number; parent: FSEntry; name: string; kind: 'directory' | 'shortcut' | 'symlink' | 'empty-file'; @@ -2331,7 +2335,7 @@ export class FSEntryStore extends PuterStore { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, - input.userId, + input.parent.userId, input.parent.id, input.parent.uuid, input.name, @@ -2776,6 +2780,7 @@ export class FSEntryStore extends PuterStore { patch: { name?: string; path?: string; + userId?: number; parentId?: number | null; parentUid?: string | null; thumbnail?: string | null; @@ -2800,6 +2805,7 @@ export class FSEntryStore extends PuterStore { if (patch.name !== undefined) push('name', patch.name); if (patch.path !== undefined) push('path', patch.path); + if (patch.userId !== undefined) push('user_id', patch.userId); if (patch.parentId !== undefined) push('parent_id', patch.parentId); if (patch.parentUid !== undefined) push('parent_uid', patch.parentUid); if (patch.thumbnail !== undefined) push('thumbnail', patch.thumbnail); @@ -2947,11 +2953,13 @@ export class FSEntryStore extends PuterStore { // Rewrites path column for every descendant of `oldPrefix` to use `newPrefix`. // Used by move/rename when a directory is relocated. Cache for affected - // entries is invalidated coarsely afterwards by the caller. + // entries is invalidated coarsely afterwards by the caller. Pass + // `newUserId` when the subtree also changes hands. async updatePathPrefixForUser( userId: number, oldPrefix: string, newPrefix: string, + newUserId?: number, ): Promise { const normalizedOld = this.#normalizePath(oldPrefix); const normalizedNew = this.#normalizePath(newPrefix); @@ -2973,12 +2981,21 @@ export class FSEntryStore extends PuterStore { postgres: '? || SUBSTR(path, ?)', otherwise: 'CONCAT(?, SUBSTR(path, ?))', }); + const reowning = newUserId !== undefined && newUserId !== userId; const result = await this.clients.db.write( `UPDATE fsentries SET path = ${rewrittenPath}, + ${reowning ? 'user_id = ?,' : ''} modified = ? WHERE user_id = ? AND path LIKE ? ESCAPE '!'`, - [normalizedNew, oldPrefixLen + 1, now, userId, likePattern], + [ + normalizedNew, + oldPrefixLen + 1, + ...(reowning ? [newUserId] : []), + now, + userId, + likePattern, + ], ); const affected = this.#affectedRows(result); return affected;