mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-24 14:16:06 +00:00
Report sharing in stat and readdir
isShared on /fs/stat and /fs/readdir, is_shared on their legacy counterparts, and return_shares now fills the shares array the legacy stat has been stubbing with []. Null for entries the caller does not own, so a share recipient is never told who else can reach the owner's files. The share-to-wire mapper moves out of ShareController so both controllers publish one shape.
This commit is contained in:
@@ -226,4 +226,131 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
const body = (await response.json()) as Array<{ name: string }>;
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
});
|
||||
|
||||
describe('the share flag', () => {
|
||||
const post = async (path: string, token: string, body: unknown) => {
|
||||
const response = await fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const mkdir = (path: string, token: string) =>
|
||||
post('/fs/mkdir', token, { path });
|
||||
|
||||
const readdir = async (path: string, token: string) => {
|
||||
const response = await fetch(readdirUrl({ path, auth_token: token }));
|
||||
expect(response.status).toBe(200);
|
||||
const entries = (await response.json()) as Array<{
|
||||
name: string;
|
||||
isShared: boolean | null;
|
||||
}>;
|
||||
return new Map(entries.map((e) => [e.name, e.isShared]));
|
||||
};
|
||||
|
||||
const base = () =>
|
||||
`/${env.users.user.username}/Documents/flag-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
it('marks a shared child in a listing, and clears it on revoke', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
await mkdir(`${parent}/private`, owner.token);
|
||||
|
||||
// A write response says nothing about sharing.
|
||||
expect(shared).not.toHaveProperty('isShared');
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
expect(await readdir(parent, owner.token)).toEqual(
|
||||
new Map([
|
||||
['shared', true],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
|
||||
await post('/share/revoke', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
});
|
||||
|
||||
expect(await readdir(parent, owner.token)).toEqual(
|
||||
new Map([
|
||||
['shared', false],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('names the recipients in stat only when asked', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const plain = await post('/fs/stat', owner.token, {
|
||||
uid: shared.uuid,
|
||||
});
|
||||
expect(plain.isShared).toBe(true);
|
||||
expect(plain).not.toHaveProperty('shares');
|
||||
|
||||
|
||||
const withShares = await post('/fs/stat', owner.token, {
|
||||
uid: shared.uuid,
|
||||
return_shares: true,
|
||||
});
|
||||
const shares = withShares.shares as Array<Record<string, unknown>>;
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0]).toMatchObject({
|
||||
holder: recipient.username,
|
||||
issuer: owner.username,
|
||||
mode: 'read',
|
||||
inherited_from: null,
|
||||
});
|
||||
for (const key of ['holder_user_id', 'issuer_user_id', 'fsentry_id']) {
|
||||
expect(shares[0]).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('tells a recipient nothing about who else can reach the item', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const stat = await post('/fs/stat', recipient.token, {
|
||||
uid: shared.uuid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.isShared).toBeNull();
|
||||
// Asked for, so the key is there — but it names nobody.
|
||||
expect(stat.shares).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
runWithConcurrencyLimitSettled,
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
@@ -972,17 +973,28 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const subtreeSize =
|
||||
entry.isDir && wantsSize
|
||||
? await this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
: undefined;
|
||||
|
||||
entry.suggestedApps =
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
? this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
this.#toBoolean(body.return_shares)
|
||||
? listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
actor,
|
||||
entry.uuid,
|
||||
)
|
||||
: undefined,
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
res.json({
|
||||
...this.#toClientEntry(entry),
|
||||
...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null),
|
||||
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
|
||||
...(shares !== undefined ? { shares } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -994,7 +1006,7 @@ export class FSController extends PuterController {
|
||||
* (with public folders enabled) any authenticated user. The legacy read
|
||||
* path already curates its output; this does the same for the v2 routes.
|
||||
*/
|
||||
#toClientEntry(entry: FSEntry): ClientFSEntry {
|
||||
#toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry {
|
||||
// Allowlist, not a denylist: a denylist silently ships every column
|
||||
// added to `fsentries` later. Omits the numeric primary keys (`id`,
|
||||
// `parentId`, `associatedAppId`), the storage columns, the owning
|
||||
@@ -1026,6 +1038,8 @@ export class FSController extends PuterController {
|
||||
workers: entry.workers ?? [],
|
||||
hasWebsite: entry.hasWebsite ?? subdomains.length > 0,
|
||||
suggestedApps: entry.suggestedApps ?? [],
|
||||
// Read paths pass a value; write responses leave it off entirely.
|
||||
...(isShared === undefined ? {} : { isShared }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1134,7 +1148,7 @@ export class FSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
const rootItems = await this.#toReaddirEntries(rootChildren);
|
||||
const rootItems = await this.#toReaddirEntries(actor, rootChildren);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: rootItems,
|
||||
@@ -1207,7 +1221,7 @@ export class FSController extends PuterController {
|
||||
)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
items: await this.#toReaddirEntries(actor, page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
@@ -1227,7 +1241,7 @@ export class FSController extends PuterController {
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
items: await this.#toReaddirEntries(actor, page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
@@ -1241,7 +1255,7 @@ export class FSController extends PuterController {
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(children);
|
||||
res.json(await this.#toReaddirEntries(children));
|
||||
res.json(await this.#toReaddirEntries(actor, children));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1249,14 +1263,20 @@ export class FSController extends PuterController {
|
||||
* the three fields the SDK cannot reconstruct on its own so it can rebuild
|
||||
* the v1 shape: `type` (MIME), a signed `thumbnail`, and `associatedApp`.
|
||||
*/
|
||||
async #toReaddirEntries(entries: FSEntry[]): Promise<ClientReaddirEntry[]> {
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
entries,
|
||||
);
|
||||
async #toReaddirEntries(
|
||||
actor: Actor,
|
||||
entries: FSEntry[],
|
||||
): Promise<ClientReaddirEntry[]> {
|
||||
const [appsById, shareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, entries),
|
||||
this.services.share.shareFlags(actor, entries),
|
||||
]);
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...this.#toClientEntry(entry),
|
||||
...this.#toClientEntry(
|
||||
entry,
|
||||
shareFlags.get(entry.uuid) ?? null,
|
||||
),
|
||||
// Fields the client cannot derive on its own.
|
||||
type: fsEntryMimeType(entry),
|
||||
thumbnail: await signEntryThumbnail(
|
||||
@@ -2193,8 +2213,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
|
||||
@@ -373,6 +373,47 @@ describe('LegacyFSController.stat', () => {
|
||||
expect(body.size).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the share flag, and the recipients when asked', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/shared-folder`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
const before = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(makeReq({ body: { path }, actor }), before.res),
|
||||
);
|
||||
expect(before.captured.body).toMatchObject({ is_shared: false });
|
||||
|
||||
await withActor(actor, () =>
|
||||
server.services.share.share(actor, {
|
||||
path,
|
||||
recipient: { username: recipient.actor.user!.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
|
||||
const after = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
after.res,
|
||||
),
|
||||
);
|
||||
const body = after.captured.body as Record<string, unknown>;
|
||||
expect(body.is_shared).toBe(true);
|
||||
expect(body.shares).toMatchObject([
|
||||
{
|
||||
holder: recipient.actor.user!.username,
|
||||
issuer: username,
|
||||
mode: 'read',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws 401 when the request has no actor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
@@ -650,6 +691,44 @@ describe('LegacyFSController.readdir', () => {
|
||||
expect(names).toContain('beta');
|
||||
});
|
||||
|
||||
it('flags a shared child in the listing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const parent = `/${username}/Documents/flagged`;
|
||||
for (const name of ['', '/shared', '/private']) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path: `${parent}${name}` }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await withActor(actor, () =>
|
||||
server.services.share.share(actor, {
|
||||
path: `${parent}/shared`,
|
||||
recipient: { username: recipient.actor.user!.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdir(makeReq({ body: { path: parent }, actor }), res),
|
||||
);
|
||||
const entries = captured.body as Array<{
|
||||
name: string;
|
||||
is_shared: boolean | null;
|
||||
}>;
|
||||
expect(new Map(entries.map((e) => [e.name, e.is_shared]))).toEqual(
|
||||
new Map([
|
||||
['shared', true],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the root listing when path = "/"', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
@@ -437,12 +438,12 @@ export class LegacyFSController extends PuterController {
|
||||
'see',
|
||||
);
|
||||
|
||||
entry.suggestedApps =
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
|
||||
const appsById = await loadLegacyAssociatedApps(this.stores.app, [
|
||||
entry,
|
||||
const [suggestedApps, appsById, shareFlags] = await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
loadLegacyAssociatedApps(this.stores.app, [entry]),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
const shaped = await toLegacyEntry(this.clients.event, entry, {
|
||||
fsEntryStore: this.stores.fsEntry,
|
||||
@@ -452,6 +453,7 @@ export class LegacyFSController extends PuterController {
|
||||
) => Promise<Record<string, unknown> | null>;
|
||||
},
|
||||
appsById,
|
||||
isShared: shareFlags.get(entry.uuid) ?? null,
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
@@ -461,13 +463,17 @@ export class LegacyFSController extends PuterController {
|
||||
entry.path,
|
||||
);
|
||||
}
|
||||
// Legacy clients sometimes ask for `return_versions`, `return_shares`.
|
||||
// We don't have parity for these yet — return empty arrays to avoid
|
||||
// breaking `response.x.forEach(...)` patterns. `return_owner` is a
|
||||
// no-op flag here: the `owner` field is already populated by
|
||||
// `toLegacyEntry` as `{ username }`.
|
||||
// `return_versions` has no parity yet; the empty array keeps `forEach`
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
if (getBoolean(body, 'return_shares')) shaped.shares = [];
|
||||
if (getBoolean(body, 'return_shares')) {
|
||||
shaped.shares = await listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
actor,
|
||||
entry.uuid,
|
||||
);
|
||||
}
|
||||
|
||||
res.json(shaped);
|
||||
};
|
||||
@@ -500,14 +506,15 @@ export class LegacyFSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
const rootAppsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
rootChildren,
|
||||
);
|
||||
const [rootAppsById, rootShareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, rootChildren),
|
||||
this.services.share.shareFlags(actor, rootChildren),
|
||||
]);
|
||||
const shaped = await Promise.all(
|
||||
rootChildren.map((c) =>
|
||||
toLegacyEntry(this.clients.event, c, {
|
||||
appsById: rootAppsById,
|
||||
isShared: rootShareFlags.get(c.uuid) ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -581,14 +588,17 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
children,
|
||||
);
|
||||
const [appsById, shareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, children),
|
||||
this.services.share.shareFlags(actor, children),
|
||||
]);
|
||||
|
||||
const shaped = await Promise.all(
|
||||
children.map((c) =>
|
||||
toLegacyEntry(this.clients.event, c, { appsById }),
|
||||
toLegacyEntry(this.clients.event, c, {
|
||||
appsById,
|
||||
isShared: shareFlags.get(c.uuid) ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -449,6 +449,8 @@ export async function toLegacyEntry(
|
||||
getById: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
appsById?: Map<number, Record<string, unknown>>;
|
||||
/** Omitted from the response when undefined. */
|
||||
isShared?: boolean | null;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Someone else's entry is published under its masked path; the owner's
|
||||
@@ -496,6 +498,7 @@ export async function toLegacyEntry(
|
||||
? (opts.appsById.get(entry.associatedAppId) ?? null)
|
||||
: null,
|
||||
appdata_app,
|
||||
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
|
||||
};
|
||||
|
||||
// `is_empty` — only meaningful for directories. Single-row probe so we
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface ClientFSEntry {
|
||||
workers: FSEntrySubdomain[];
|
||||
hasWebsite: boolean;
|
||||
suggestedApps: unknown[];
|
||||
isShared?: boolean | null;
|
||||
|
||||
id?: never;
|
||||
userId?: never;
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
ShareTarget,
|
||||
} from '../../services/share/ShareService.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { signEntryThumbnail } from '../fs/legacyFsHelpers.js';
|
||||
import { toClientShare } from './clientShare.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -443,47 +443,8 @@ export class ShareController extends PuterController {
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only ever the username — never the internal id, and never an email the
|
||||
* caller didn't already supply.
|
||||
*
|
||||
* `thumbnail` is stored as an `s3://bucket/key` URI, so it is swapped for a
|
||||
* signed URL rather than emitted: the raw value names internal storage and
|
||||
* no client can render it.
|
||||
*/
|
||||
async #toClientShare(share: ResolvedShare) {
|
||||
const thumbnail =
|
||||
share.thumbnail === undefined
|
||||
? undefined
|
||||
: await signEntryThumbnail(
|
||||
this.clients.event,
|
||||
share.entryUid,
|
||||
share.thumbnail,
|
||||
);
|
||||
return {
|
||||
uid: share.uid,
|
||||
mode: share.mode,
|
||||
path: share.path,
|
||||
// A share listing has no fsentry behind it for a client to stat.
|
||||
...(share.name === undefined ? {} : { name: share.name }),
|
||||
...(share.type === undefined ? {} : { type: share.type }),
|
||||
...(thumbnail === undefined ? {} : { thumbnail }),
|
||||
...(share.owner === undefined
|
||||
? {}
|
||||
: { owner: share.owner.username }),
|
||||
...(share.pending
|
||||
? { pending: true, recipient_email: share.recipientEmail }
|
||||
: {}),
|
||||
uid_entry: share.entryUid,
|
||||
is_dir: share.isDir,
|
||||
issuer: share.issuer.username,
|
||||
holder: share.holder.username,
|
||||
created_at: share.createdAt,
|
||||
issued_by_app: share.issuedByApp ?? null,
|
||||
inherited_from: share.inheritedFrom ?? null,
|
||||
modified: share.modified,
|
||||
size: share.size,
|
||||
};
|
||||
#toClientShare(share: ResolvedShare) {
|
||||
return toClientShare(this.clients.event, share);
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import type {
|
||||
ResolvedShare,
|
||||
ShareService,
|
||||
} from '../../services/share/ShareService.js';
|
||||
import { signEntryThumbnail } from '../fs/legacyFsHelpers.js';
|
||||
|
||||
/**
|
||||
* One share on the wire: only ever the username — never the internal id, and
|
||||
* never an email the caller didn't already supply.
|
||||
*
|
||||
* `thumbnail` is stored as an `s3://bucket/key` URI, so it is swapped for a
|
||||
* signed URL rather than emitted: the raw value names internal storage and no
|
||||
* client can render it.
|
||||
*/
|
||||
export async function toClientShare(
|
||||
eventClient: EventClient | undefined,
|
||||
share: ResolvedShare,
|
||||
) {
|
||||
const thumbnail =
|
||||
share.thumbnail === undefined
|
||||
? undefined
|
||||
: await signEntryThumbnail(
|
||||
eventClient,
|
||||
share.entryUid,
|
||||
share.thumbnail,
|
||||
);
|
||||
return {
|
||||
uid: share.uid,
|
||||
mode: share.mode,
|
||||
path: share.path,
|
||||
// A share listing has no fsentry behind it for a client to stat.
|
||||
...(share.name === undefined ? {} : { name: share.name }),
|
||||
...(share.type === undefined ? {} : { type: share.type }),
|
||||
...(thumbnail === undefined ? {} : { thumbnail }),
|
||||
...(share.owner === undefined ? {} : { owner: share.owner.username }),
|
||||
...(share.pending
|
||||
? { pending: true, recipient_email: share.recipientEmail }
|
||||
: {}),
|
||||
uid_entry: share.entryUid,
|
||||
is_dir: share.isDir,
|
||||
issuer: share.issuer.username,
|
||||
holder: share.holder.username,
|
||||
created_at: share.createdAt,
|
||||
issued_by_app: share.issuedByApp ?? null,
|
||||
inherited_from: share.inheritedFrom ?? null,
|
||||
modified: share.modified,
|
||||
size: share.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** Who can reach `uid`, or empty when the caller may not manage it. */
|
||||
export async function listClientShares(
|
||||
shareService: ShareService,
|
||||
eventClient: EventClient | undefined,
|
||||
actor: Actor,
|
||||
uid: string,
|
||||
) {
|
||||
const shares = await shareService.tryListSharesOf(actor, { uid });
|
||||
if (shares === null) return [];
|
||||
return Promise.all(
|
||||
shares.map((share) => toClientShare(eventClient, share)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user