diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index dc1264bd6..879e39fa8 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -96,6 +96,7 @@ export type { export type { CopyOptions, DeleteOptions, + FSItemWithShares, GetSharesOptions, ListSharedOptions, MkdirOptions, diff --git a/src/puter-js/src/modules/FileSystem/operations/operations.test.js b/src/puter-js/src/modules/FileSystem/operations/operations.test.js index a9cf968fa..bc41d3d28 100644 --- a/src/puter-js/src/modules/FileSystem/operations/operations.test.js +++ b/src/puter-js/src/modules/FileSystem/operations/operations.test.js @@ -382,6 +382,43 @@ describe('stat', () => { await fs.stat({ path: '/a/file.txt', consistency: 'eventual' }); expect(FakeXHR.requests).toHaveLength(0); }); + + it('asks for shares and publishes them in the SDK shape', async () => { + FakeXHR.respondWith = () => ({ + uid: 'u1', + is_dir: false, + is_shared: true, + shares: [ + { + uid: 's1', + mode: 'write', + uid_entry: 'u1', + is_dir: false, + holder: 'someone', + inherited_from: null, + }, + ], + }); + const item = await fs.stat('/a/file.txt', { returnShares: true }); + expect(lastBody()).toMatchObject({ return_shares: true }); + expect(item.is_shared).toBe(true); + expect(item.shares[0]).toMatchObject({ + uid: 's1', + mode: 'write', + entryUid: 'u1', + holder: 'someone', + inheritedFrom: null, + }); + }); + + it('keeps a share-carrying result out of the cache', async () => { + FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, shares: [] }); + await fs.stat('/a/cached.txt', { returnShares: true }); + FakeXHR.requests = []; + // Would be served from the cache had the previous call populated it. + await fs.stat({ path: '/a/cached.txt', consistency: 'eventual' }); + expect(FakeXHR.requests).toHaveLength(1); + }); }); describe('readdir', () => { @@ -396,6 +433,15 @@ describe('readdir', () => { expect(entries[0]).toMatchObject({ name: 'file.txt', is_dir: false, uid: 'u1' }); }); + it('carries the share flag into the v1 shape', async () => { + FakeXHR.respondWith = () => [ + { uuid: 'u1', name: 'mine.txt', path: '/a/mine.txt', isShared: true }, + { uuid: 'u2', name: 'theirs.txt', path: '/a/theirs.txt', isShared: null }, + ]; + const entries = await fs.readdir('/a'); + expect(entries.map((e) => e.is_shared)).toEqual([true, null]); + }); + it('readdir(path, options) applies the options', async () => { await fs.readdir('/a', { limit: 5, sortBy: 'modified', sortOrder: 'desc' }); expect(lastBody()).toMatchObject({ limit: 5, sortBy: 'modified', sortOrder: 'desc' }); diff --git a/src/puter-js/src/modules/FileSystem/operations/stat.js b/src/puter-js/src/modules/FileSystem/operations/stat.js index 2446a555e..bc275dc94 100644 --- a/src/puter-js/src/modules/FileSystem/operations/stat.js +++ b/src/puter-js/src/modules/FileSystem/operations/stat.js @@ -1,15 +1,24 @@ import { dedupe } from '../../../lib/networkUtils.js'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { fsRequest, parseOperationArgs } from './scaffold.js'; +import { toShare } from './shareUtil.js'; /** @typedef {import('../types.js').StatOptions} StatOptions */ +/** @typedef {import('../types.js').FSItemWithShares} FSItemWithShares */ /** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * @typedef {{ + * (options: StatOptions & { returnShares: true }): Promise, * (options: StatOptions): Promise, * ( * path: string, + * options: StatOptions & { returnShares: true }, + * success?: (value: FSItemWithShares) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * ( + * path: string, * options?: StatOptions, * success?: (value: FSItem) => void, * error?: (reason: unknown) => void, @@ -50,7 +59,7 @@ const statImpl = async function (...args) { cacheKey = `item:${ options.path}`; } - if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize ) { + if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) { const cachedResult = await puter._cache.get(cacheKey); if ( cachedResult ) { return cachedResult; @@ -65,6 +74,7 @@ const statImpl = async function (...args) { returnPermissions: options.returnPermissions, returnVersions: options.returnVersions, returnSize: options.returnSize, + returnShares: options.returnShares, consistency: options.consistency, }); @@ -80,6 +90,7 @@ const statImpl = async function (...args) { body.return_permissions = options.returnPermissions; body.return_versions = options.returnVersions; body.return_size = options.returnSize; + body.return_shares = options.returnShares; body.auth_token = this.authToken; return fsRequest.call(this, { @@ -90,7 +101,11 @@ const statImpl = async function (...args) { success: options.success, error: options.error, transform: (result) => { - if ( JSON.stringify(result).length <= MAX_CACHE_SIZE ) { + if ( Array.isArray(result?.shares) ) { + result.shares = result.shares.map(toShare); + } + // Not cached — a later plain stat must not serve share data. + if ( ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) { puter._cache.set(cacheKey, result); } return result; diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index b6642f831..75363bf1f 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -164,6 +164,8 @@ * @property {boolean} [returnPermissions] Whether to return permission information. Defaults to `false`. * @property {boolean} [returnVersions] Whether to return version information. Defaults to `false`. * @property {boolean} [returnSize] Whether to return size information. Defaults to `false`. + * @property {boolean} [returnShares] Whether to return who the item is shared with, as a `shares` + * array on the result. Empty unless you can manage the item. Defaults to `false`. */ /** @@ -172,6 +174,12 @@ * @typedef {StatOptionsOwn & RequestCallbacks} StatOptions */ +/** + * A `stat()` result with `returnShares` set. + * + * @typedef {FSItem & { shares: Share[] }} FSItemWithShares + */ + /** * @typedef {Object} UploadOptionsOwn * @property {boolean} [overwrite] Whether to overwrite the destination file if it already exists. diff --git a/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js b/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js index 99ef2a482..614495787 100644 --- a/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js +++ b/src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js @@ -39,6 +39,7 @@ const mapV2EntryToV1 = (entry) => { type: entry.type ?? null, writable: true, is_public: entry.isPublic ?? null, + is_shared: entry.isShared ?? null, thumbnail: entry.thumbnail ?? null, immutable: Boolean(entry.immutable), metadata: entry.metadata ?? null, diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts index 72ba615f8..9b14a91db 100644 --- a/src/puter-js/tests/api/suites/sharing.suite.ts +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -79,6 +79,37 @@ export default suite('sharing', { t.assert.equal(shares[0].issuer, t.env.users.user.username); }, + 'stat and readdir report whether an item is shared': async (t) => { + // A directory of its own, so the listing is just these two entries. + const dir = scratch(t, 'flag').replace(/\.txt$/, ''); + const shared = `${dir}/shared.txt`; + const sibling = `${dir}/sibling.txt`; + await t.puter.fs.write(shared, 'x', { createMissingParents: true }); + await t.puter.fs.write(sibling, 'x'); + await t.puter.fs.share(shared, t.env.users.other.username, 'write'); + + const listing = await t.puter.fs.readdir(dir); + const flags = Object.fromEntries( + listing.map((item) => [item.name, item.is_shared]), + ); + t.assert.equal(flags['shared.txt'], true); + t.assert.equal(flags['sibling.txt'], false); + + const item = await t.puter.fs.stat(shared, { returnShares: true }); + t.assert.equal(item.is_shared, true); + t.assert.equal(item.shares.length, 1); + t.assert.equal(item.shares[0].holder, t.env.users.other.username); + t.assert.equal(item.shares[0].mode, 'write'); + // Mapped into the same shape `getShares()` publishes. + t.assert.equal(item.shares[0].entryUid, item.uid); + t.assert.equal(item.shares[0].inheritedFrom, null); + + await t.puter.fs.unshare(shared, t.env.users.other.username); + const after = await t.puter.fs.stat(shared); + t.assert.equal(after.is_shared, false); + t.assert.equal(after.shares, undefined); + }, + 'changing the mode replaces the share rather than adding one': async (t) => { const path = scratch(t, 'remode'); await t.puter.fs.write(path, 'x');