diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 9af29017f..44c9e6c9e 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -2282,6 +2282,7 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult { const updated: Record = {}; const notUpdated: Record = {}; const destroyed: string[] = []; + const notDestroyed: Record = {}; if (args.create) { for (const [tempId, props] of Object.entries(args.create as Record>)) { @@ -2336,9 +2337,17 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult { } if (Array.isArray(args.destroy)) { - // The client always sends onDestroyRemoveChildren: true, so a destroyed - // folder takes its subtree with it. - const toDestroy = new Set(args.destroy as string[]); + const toDestroy = new Set(); + for (const id of args.destroy as string[]) { + // Like Stalwart, a folder that still holds anything goes only with + // onDestroyRemoveChildren (the rollback of a failed copy leaves it out). + if (!args.onDestroyRemoveChildren && fileNodes.some((n) => n.parentId === id)) { + notDestroyed[id] = { type: 'nodeHasChildren', description: 'Cannot delete non-empty folder.' }; + } else { + toDestroy.add(id); + } + } + // A destroyed folder takes its subtree with it. let grew = true; while (grew) { grew = false; @@ -2366,6 +2375,7 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult { updated, notUpdated, destroyed, + notDestroyed, }, callId]; } diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts index 636a837b6..e8a0b5216 100644 --- a/app/api/wopi/launch/route.ts +++ b/app/api/wopi/launch/route.ts @@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; -import { fetchJmapSession } from '@/lib/stalwart/jmap-api'; +import { fetchJmapSession, type JmapSessionDocument } from '@/lib/stalwart/jmap-api'; import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery'; -import { getWopiFileNode, probeBlob } from '@/lib/wopi/files'; +import { getWopiFileNode, probeBlob, shareeSavesNeedRename } from '@/lib/wopi/files'; import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token'; /** @@ -56,8 +56,9 @@ export async function POST(request: NextRequest) { // needs the user's own account, where PutFile uploads (uploadAccountId). let accountId = str('accountId'); let ownAccountId = ''; + let session: JmapSessionDocument | null = null; if (!accountId || !blobId) { - const session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted }); + session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted }); const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode'; ownAccountId = session?.primaryAccounts?.[capability] || @@ -90,7 +91,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'File not found' }, { status: 404 }); } name = node.name; - canWrite = node.myRights ? !!node.myRights.mayModifyContent : true; + // Editable only with the rights a save needs, or every save fails: + // Stalwart 0.16 also wants the Modify right (mayRename) from a sharee. + const rights = node.myRights; + const needsRename = accountId !== ownAccountId && shareeSavesNeedRename(session, accountId, ownAccountId); + canWrite = !rights || (!!rights.mayModifyContent && (!needsRename || !!rights.mayRename)); document = { kind: 'file', fileId }; } diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 199b91e26..451e08aaf 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -10,8 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store"; -import { fileErrorKind } from "@/lib/file-errors"; +import { useFileStore, isSharedLocation, resourceServerRef, type UploadReport } from "@/stores/file-store"; +import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -34,7 +34,7 @@ import type { FolderLayout } from "@/components/files/files-settings-dialog"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { AlertTriangle, Loader2 } from "@/components/icons"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links"; +import { appPath, buildFilesPath, buildSharedFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links"; import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; @@ -108,6 +108,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { undoLastAction, lastAction, shareResource, + openSharedNode, } = useFileStore(); const isMobile = useIsMobile(); @@ -223,23 +224,40 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { return link; }, []); - // Pro shell only: this surface stays mounted for the whole session, so links - // arriving after mount are delivered live instead of being parked forever. - // The bootstrap has already run by then - walk the drive directly and let - // the preview effect below claim `pendingPreviewRef` once the listing lands. - // Embedded-only: during a cold-load redirect the standard instance renders - // briefly and must not steal the link parked for the Pro one. + // A node of a drive shared with the user has no path under the user's own + // root, so its link names it by id and the store opens where it is listed. + // False when it is gone or no longer shared. + const openSharedLink = useCallback(async (id: string): Promise => { + if (await openSharedNode(id, (name) => t("shared_files_from", { name }))) return true; + toast.error(tDeepLink(pendingPreviewRef.current !== null ? "file_not_found" : "folder_not_found")); + // Not a file of the same name in whatever folder is open instead. + pendingPreviewRef.current = null; + return false; + }, [openSharedNode, t, tDeepLink]); + const openSharedLinkRef = useRef(openSharedLink); + openSharedLinkRef.current = openSharedLink; + + // Links arriving after mount are delivered live. The Pro shell keeps this + // surface mounted for the whole session, and so does Next for the standard + // page when a link changes only the query (`?shared=`), while the route is + // read on mount only. The bootstrap has already run by then - walk the + // drive directly and let the preview effect below claim `pendingPreviewRef` + // once the listing lands. While the Pro interface is on, the standard + // instance renders only during a cold-load redirect and must not steal the + // link parked for the Pro one. + const proInterfaceActive = useProInterfaceActive(); const navigateByPathRef = useRef(navigateByPath); navigateByPathRef.current = navigateByPath; useEffect(() => { - if (!isEmbedded) return; + if (!isEmbedded && proInterfaceActive) return; return subscribePendingDeepLink('files', (segments, search) => { const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search)); if (!link) return; if (link.preview) pendingPreviewRef.current = link.preview; - void navigateByPathRef.current(link.path); + if (link.shared) void openSharedLinkRef.current(link.shared); + else void navigateByPathRef.current(link.path); }); - }, [isEmbedded]); + }, [isEmbedded, proInterfaceActive]); // Check support and load the first listing after the client is initialized. // One effect, run once: `checkSupport` publishes `supportsFiles` before it @@ -267,7 +285,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { // listing the root first would strand the user there and rewrite the // URL they arrived on. const link = takeFilesLink(); - if (link && link.path !== '/') await navigateByPath(link.path); + if (link?.shared && await openSharedLinkRef.current(link.shared)) return; + if (link && !link.shared && link.path !== '/') await navigateByPath(link.path); else if (firstCheck) await navigate(null); })(); }, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]); @@ -310,7 +329,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { // What the server said went wrong, as the second line under an action's // own error message; undefined when it said nothing specific. - const failureReason = useCallback((err: unknown): string | undefined => { + const serverReason = useCallback((err: unknown): string | undefined => { switch (fileErrorKind(err)) { case "overQuota": return t("error_over_quota"); case "tooDeep": return t("error_too_deep"); @@ -320,6 +339,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { default: return undefined; } }, [t]); + // A copy that stopped part-way and could not all be taken back says so too. + const failureReason = useCallback((err: unknown): string | undefined => { + const reason = serverReason(err); + if (!isPartialCopy(err)) return reason; + return reason ? `${reason} ${t("error_partial_copy")}` : t("error_partial_copy"); + }, [serverReason, t]); // An upload goes on past a file the server refuses; the refused ones are // reported once it is done. @@ -536,9 +561,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { // The permalink for the folder on screen. In the Pro shell only the focused // tab writes the address bar; the standard instance that renders while Pro // takes over a route stays silent. - const proInterfaceActive = useProInterfaceActive(); const isFocusedProTab = useIsFocusedProTab(); - const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile)); + // A shared drive's folders and pages are linked by id (see openSharedLink). + const currentParentId = useFileStore((s) => s.currentParentId); + const filesLinkPath = appPath(isSharedLocation(currentParentId) + ? buildSharedFilesPath(currentParentId, previewFile) + : buildFilesPath(currentPath, previewFile)); useDeepLinkUrl( isEmbedded ? (isFocusedProTab ? filesLinkPath : null) diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index af8339c69..2f2ac8040 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { + File, Folder, FolderOpen, ChevronRight, @@ -11,7 +12,7 @@ import { Share2, } from "@/components/icons"; import { cn } from "@/lib/utils"; -import { useFileStore, type FileResource } from "@/stores/file-store"; +import { useFileStore, sharedFilesLocation, type FileResource } from "@/stores/file-store"; interface FolderNode { id: string; @@ -132,6 +133,21 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid onNavigate(path, id); }, [onNavigate]); + const sharedFolders = sharedRoots.filter(r => r.isDirectory); + // Files shared on their own sit in no shared folder; each owner's are + // listed on one page. + const fileOwners = new Map(); + for (const r of sharedRoots) { + if (!r.isDirectory && r.ownerAccountId) fileOwners.set(r.ownerAccountId, r.ownerName || r.ownerAccountId); + } + const sharedFilePages = [...fileOwners] + .sort(([, a], [, b]) => a.localeCompare(b)) + .map(([accountId, ownerName]) => ({ + id: sharedFilesLocation(accountId), + name: t("shared_files_from", { name: ownerName }), + ownerName, + })); + return (
r.isDirectory).length > 0 && ( + {/* Shared with me: what another principal has shared with the user */} + {(sharedFolders.length > 0 || sharedFilePages.length > 0) && (
{t("shared_with_me")}
- {sharedRoots.filter(r => r.isDirectory).map(r => { + {sharedFolders.map(r => { const path = `/${r.name}`; const isSelected = currentPath === path; return ( @@ -217,6 +233,30 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
); })} + {sharedFilePages.map(page => { + const path = `/${page.name}`; + const isSelected = currentPath === path; + return ( +
+ +
+ ); + })}
)} diff --git a/lib/__tests__/deep-link-handoff.test.ts b/lib/__tests__/deep-link-handoff.test.ts index cc1b69d13..92d6e4c9d 100644 --- a/lib/__tests__/deep-link-handoff.test.ts +++ b/lib/__tests__/deep-link-handoff.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { setPendingDeepLink, + deliverDeepLink, consumePendingDeepLink, consumePendingDeepLinkEntry, subscribePendingDeepLink, @@ -82,4 +83,16 @@ describe('deep-link handoff', () => { expect(consumePendingDeepLinkEntry('files')).toBeNull(); unsubscribe(); }); + + it('delivers only to a mounted surface and parks nothing when none listens', () => { + expect(deliverDeepLink('files', [], '?shared=owner1%3An5')).toBe(false); + expect(consumePendingDeepLinkEntry('files')).toBeNull(); + + const listener = vi.fn(); + const unsubscribe = subscribePendingDeepLink('files', listener); + expect(deliverDeepLink('files', [], '?shared=owner1%3An5')).toBe(true); + expect(listener).toHaveBeenCalledWith([], '?shared=owner1%3An5'); + expect(consumePendingDeepLinkEntry('files')).toBeNull(); + unsubscribe(); + }); }); diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index 6c0bb17ad..e7785090b 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -11,6 +11,7 @@ import { buildContactsPath, parseContactsPath, buildFilesPath, + buildSharedFilesPath, parseFilesPath, buildSettingsPath, parseSettingsPath, @@ -272,6 +273,17 @@ describe('files paths', () => { expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf'))) .toEqual({ path: '/Documents', preview: 'report.pdf' }); }); + + it('names a node of a shared drive by id, since it has no path under the user\'s root', () => { + const link = buildSharedFilesPath('owner1:n5', 'Q3 plan.pdf'); + expect(link).toBe('/files?shared=owner1%3An5&preview=Q3%20plan.pdf'); + const [, query] = link.split('?'); + expect(parseFilesPath([], new URLSearchParams(query))) + .toEqual({ path: '/', preview: 'Q3 plan.pdf', shared: 'owner1:n5' }); + // An owner's page of files shared on their own. + expect(parseFilesPath([], new URLSearchParams(buildSharedFilesPath('owner1:*').split('?')[1]))) + .toEqual({ path: '/', preview: undefined, shared: 'owner1:*' }); + }); }); describe('settings paths', () => { diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 56950158c..98e9b67a7 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -463,5 +463,22 @@ describe('dev-jmap mock server', () => { expect(created.uploaded.size).toBe(5); expect(created.copied.size).toBe(148480); }); + + it('keeps a folder that still holds something unless onDestroyRemoveChildren is set, like a real server', async () => { + const fileNodeSet = async (args: Record) => { + const res = await POST(makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ methodCalls: [['FileNode/set', { accountId: 'dev-account-001', ...args }, '0']] }), + }), { params: Promise.resolve({ path: ['api'] }) }); + return (await res.json()).methodResponses[0][1]; + }; + const folder = (await fileNodeSet({ create: { f: { name: 'Rollback test' } } })).created.f.id; + const inside = (await fileNodeSet({ create: { x: { name: 'x.pdf', type: 'application/pdf', blobId: 'demo-blob-file-2', parentId: folder } } })).created.x.id; + + expect((await fileNodeSet({ destroy: [folder] })).notDestroyed[folder].type).toBe('nodeHasChildren'); + expect((await fileNodeSet({ destroy: [inside] })).destroyed).toEqual([inside]); + expect((await fileNodeSet({ destroy: [folder] })).destroyed).toEqual([folder]); + }); }); }); diff --git a/lib/__tests__/file-errors.test.ts b/lib/__tests__/file-errors.test.ts index 0d52aa891..9ab78906a 100644 --- a/lib/__tests__/file-errors.test.ts +++ b/lib/__tests__/file-errors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { fileErrorKind, folderDepthError } from '../file-errors'; +import { fileErrorKind, folderDepthError, isPartialCopy, partialCopyError } from '../file-errors'; import { jmapError } from '../jmap/jmap-error'; // SetErrors as Stalwart words them (1.0: jmap/src/file/*.rs; 0.16.23: @@ -31,6 +31,17 @@ describe('fileErrorKind', () => { expect(fileErrorKind(setError('forbidden', 'You are not allowed to modify this file node.'))).toBe('forbidden'); }); + it('keeps why a copy stopped when part of it was left in place', () => { + const cause = setError('overQuota', 'There are too many files, please delete some before adding a new one.'); + const partial = partialCopyError(cause); + expect(isPartialCopy(partial)).toBe(true); + expect(fileErrorKind(partial)).toBe('overQuota'); + expect(partial.message).toBe(cause.message); + expect(fileErrorKind(partialCopyError(folderDepthError()))).toBe('tooDeep'); + expect(isPartialCopy(partialCopyError(new TypeError('Failed to fetch')))).toBe(true); + expect(isPartialCopy(cause)).toBe(false); + }); + it('has nothing to add for other errors', () => { // The folder is gone, or a folder was moved into itself. expect(fileErrorKind(setError('invalidProperties', 'Parent ID does not exist or is not a folder.', ['parentId']))).toBeNull(); diff --git a/lib/__tests__/jmap-filenode-writes.test.ts b/lib/__tests__/jmap-filenode-writes.test.ts index f8e7d9042..37a8c1422 100644 --- a/lib/__tests__/jmap-filenode-writes.test.ts +++ b/lib/__tests__/jmap-filenode-writes.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { JMAPClient } from '../jmap/client'; +import type { FileNodeListings } from '../jmap/client-interface'; import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error'; -import { fileErrorKind } from '../file-errors'; +import { fileErrorKind, isPartialCopy } from '../file-errors'; type Call = [string, Record, string]; @@ -14,6 +15,8 @@ interface FakeOptions { capability?: Record; /** Null: "grp" lists no filenode capability, as a shared account may. */ sharedCapability?: Record | null; + /** Someone else puts a node into the created node of this name right after it is made. */ + intrudeInto?: string; } // The rules Stalwart 1.0 publishes (0.16.23: the same with maxFileNodeDepth null). @@ -45,6 +48,9 @@ function makeClient(nodes: Record>>, optio .mockReturnValue(['me', 'grp']); const calls: Call[] = []; let seq = 0; + // Parent of every node the fake has created or been told about, to answer + // destroys the way Stalwart does. + const parents = new Map(); vi.spyOn(client as unknown as { request: (c: Call[]) => Promise }, 'request') .mockImplementation(async (reqCalls: Call[]) => { const [method, args, id] = reqCalls[0]; @@ -57,9 +63,13 @@ function makeClient(nodes: Record>>, optio return { methodResponses: [['Blob/copy', { copied: Object.fromEntries(blobIds.map(b => [b, `${b}-copy`])) }, id]] }; } if (method === 'FileNode/set' && args.create) { - const refusal = options.refuseCreate?.((args.create as { n0: Record }).n0); + const props = (args.create as { n0: Record }).n0; + const refusal = options.refuseCreate?.(props); if (refusal) return { methodResponses: [['FileNode/set', { notCreated: { n0: refusal } }, id]] }; - return { methodResponses: [['FileNode/set', { created: { n0: { id: `new${++seq}` } } }, id]] }; + const created = `new${++seq}`; + parents.set(created, (props.parentId as string | undefined) ?? null); + if (props.name === options.intrudeInto) parents.set('theirs', created); + return { methodResponses: [['FileNode/set', { created: { n0: { id: created } } }, id]] }; } if (method === 'FileNode/set' && args.update) { const ids = Object.keys(args.update as object); @@ -71,15 +81,26 @@ function makeClient(nodes: Record>>, optio } if (method === 'FileNode/set' && args.destroy) { const ids = args.destroy as string[]; - const refused = ids.filter(k => options.refuse?.(k)); - return { methodResponses: [['FileNode/set', { - destroyed: ids.filter(k => !refused.includes(k)), - notDestroyed: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])), - }, id]] }; + const destroyed: string[] = []; + const notDestroyed: Record = {}; + // Without onDestroyRemoveChildren, a folder that still holds anything + // is refused. 0.16 checks against the tree as it was before the call, + // so children destroyed in the same call still count (willDestroy). + const childrenBefore = new Map(ids.map(k => [k, [...parents].filter(([, p]) => p === k).map(([c]) => c)])); + for (const k of ids) { + const refusal = options.refuse?.(k); + const children = childrenBefore.get(k) ?? []; + if (refusal) notDestroyed[k] = refusal; + else if (args.onDestroyRemoveChildren || children.length === 0) destroyed.push(k); + else if (children.some(c => ids.includes(c))) notDestroyed[k] = { type: 'willDestroy', description: 'File node or one of its children is already marked for deletion.' }; + else notDestroyed[k] = { type: 'nodeHasChildren', description: 'Cannot delete non-empty folder.' }; + } + for (const k of destroyed) parents.delete(k); + return { methodResponses: [['FileNode/set', { destroyed, notDestroyed }, id]] }; } throw new Error(`unexpected ${method}`); }); - return { client, calls }; + return { client, calls, parents }; } const sets = (calls: Call[]) => calls.filter(([m]) => m === 'FileNode/set').map(([, a]) => a); @@ -233,6 +254,8 @@ describe('FileNode writes', () => { { id: 'f2', name: 'b.txt', parentId: 'd1', blobId: 'b2', type: 'text/plain', size: 4 }, ]; + const destroys = (calls: Call[]) => sets(calls).filter(a => a.destroy); + it('removes the half-made copy and reports why when a create fails part-way on Stalwart 1.0', async () => { const { client, calls } = makeClient({ me: docs }, { // 1.0 once an administrator has set maxFiles. @@ -241,8 +264,15 @@ describe('FileNode writes', () => { : undefined, }); - await expect(client.copyFileNode('d1', 'Docs', 'grp:d9')).rejects.toThrow('There are too many files'); - expect(sets(calls).at(-1)).toEqual({ accountId: 'grp', destroy: ['new1'], onDestroyRemoveChildren: true }); + const refused = await client.copyFileNode('d1', 'Docs', 'grp:d9').catch((error: unknown) => error); + expect(refused).toBeInstanceOf(Error); + expect((refused as Error).message).toContain('There are too many files'); + expect(isPartialCopy(refused)).toBe(false); + // Only what the copy made, the file before its folder. + expect(destroys(calls)).toEqual([ + { accountId: 'grp', destroy: ['new2'] }, + { accountId: 'grp', destroy: ['new1'] }, + ]); }); it('removes the half-made copy when the server refuses a blob on 0.16', async () => { @@ -253,7 +283,50 @@ describe('FileNode writes', () => { }); await expect(client.copyFileNode('grp:d1', 'Docs', null)).rejects.toThrow('You do not have access to blobId b2.'); - expect(sets(calls).at(-1)).toEqual({ accountId: 'me', destroy: ['new1'], onDestroyRemoveChildren: true }); + expect(destroys(calls)).toEqual([ + { accountId: 'me', destroy: ['new2'] }, + { accountId: 'me', destroy: ['new1'] }, + ]); + }); + + // Docs > a.txt, Sub > b.txt, c.txt: c.txt is refused after the rest was made. + const nested = [ + { id: 'd1', name: 'Docs', parentId: null, blobId: null }, + { id: 'f1', name: 'a.txt', parentId: 'd1', blobId: 'b1', type: 'text/plain', size: 3 }, + { id: 'd2', name: 'Sub', parentId: 'd1', blobId: null }, + { id: 'f2', name: 'b.txt', parentId: 'd2', blobId: 'b2', type: 'text/plain', size: 4 }, + { id: 'f3', name: 'c.txt', parentId: 'd1', blobId: 'b3', type: 'text/plain', size: 5 }, + ]; + const quotaOnC = (props: Record) => props.name === 'c.txt' + ? { type: 'overQuota', description: 'There are too many files, please delete some before adding a new one.' } + : undefined; + + it('takes a copy back one level per call, deepest first, without onDestroyRemoveChildren (0.16 and 1.0)', async () => { + // The fake answers like 0.16: a folder destroyed in the same call as its + // children is refused (willDestroy). 1.0 takes either. + const { client, calls, parents } = makeClient({ me: nested }, { refuseCreate: quotaOnC }); + + await expect(client.copyFileNode('d1', 'Docs', 'grp:d9')).rejects.toThrow('There are too many files'); + expect(destroys(calls)).toEqual([ + { accountId: 'grp', destroy: ['new4'] }, + { accountId: 'grp', destroy: ['new2', 'new3'] }, + { accountId: 'grp', destroy: ['new1'] }, + ]); + expect([...parents.keys()]).toEqual([]); + }); + + it('leaves a folder of the copy that someone added to meanwhile, and says the copy is partly there (0.16 and 1.0)', async () => { + const { client, calls, parents } = makeClient({ me: nested }, { refuseCreate: quotaOnC, intrudeInto: 'Sub' }); + + const refused = await client.copyFileNode('d1', 'Docs', 'grp:d9').catch((error: unknown) => error); + expect(isPartialCopy(refused)).toBe(true); + // Still says why the copy stopped. + expect(fileErrorKind(refused)).toBe('overQuota'); + expect((refused as Error).message).toContain('There are too many files'); + // Their file stays, and so do Sub (new3) and Docs (new1) above it. + expect(destroys(calls).every(a => !('onDestroyRemoveChildren' in a))).toBe(true); + expect(destroys(calls).flatMap(a => a.destroy as string[])).not.toContain('theirs'); + expect([...parents.keys()].sort()).toEqual(['new1', 'new3', 'theirs']); }); it('has nothing to remove when the copy itself is refused', async () => { @@ -349,4 +422,15 @@ describe('FileNode write errors, names and types', () => { await client.copyFileNode('src', 'src', 'target'); expect(sets(calls)).toHaveLength(3); }); + + it('reads each account\'s tree once for all the copies of one paste (Stalwart 1.0)', async () => { + const { client, calls } = makeClient({ me: tree, grp: tree }, { capability: STALWART_10_CAPABILITY }); + const listings: FileNodeListings = new Map(); + await client.copyFileNode('src', 'src', 'grp:target', listings); + await client.copyFileNode('f', 'f.txt', 'grp:target', listings); + + // The source tree, then the target's for the depth check; not again per item. + expect(calls.filter(([m]) => m === 'FileNode/get').map(([, a]) => a.accountId)).toEqual(['me', 'grp']); + expect(sets(calls)).toHaveLength(4); + }); }); diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index 28524baa4..2ad8b907d 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -196,6 +196,9 @@ describe('lite build helpers', () => { expect(readme).toContain('stalwart-cli update Http --field usePermissiveCors=true'); expect(readme).toContain('{"update": {"singleton": {"usePermissiveCors": true}}}'); expect(readme).toContain('ReloadSettings'); + // The 1.0 pre-releases can't reload settings while the stock spam-filter + // rules use a function 1.0 removed; a restart applies the change there. + expect(readme).toMatch(/On the current 1\.0 pre-releases `ReloadSettings` fails while the\s+stock spam-filter rules are loaded, so restart Stalwart there instead\./); expect(readme).not.toContain('permissive-cors = true'); expect(readme).toContain('currently `https://m.example`'); }); @@ -599,13 +602,17 @@ describe('Stalwart target: the entry document', () => { const readme = buildStalwartReadme({ version: '1.10.0', commit: 'abc1234', locales: ['en'], buildId: 'b' }); expect(readme).toContain('Requires: Stalwart 0.16.6 or later (0.16.19 for `oauthClientId`).'); // Only 0.16.23 keeps the running bundle; 0.16.0-0.16.22 and the 1.0 - // pre-releases unmount the Application, and a restart with an expired cache - // and an unreachable URL leaves it unmounted everywhere. + // pre-releases unmount the Application and delete the cached zip, so any + // later unpack downloads again: a restart, UpdateApps, or a reload of the + // Applications (cluster peers run one after UpdateApps). A restart with an + // expired cache and an unreachable URL leaves it unmounted everywhere. expect(readme).not.toContain('A failed download keeps the previous bundle online'); expect(readme).toMatch(/On 0\.16\.23 the\s+running bundle stays online/); - expect(readme).toMatch(/Older 0\.16 releases and the 1\.0 pre-releases\s+unmount the Application until a later `UpdateApps` succeeds/); - expect(readme).toMatch(/On every\s+version, a restart after the cached copy has expired/); - expect(readme).toMatch(/check that `resourceUrl`\s+downloads before running `UpdateApps`/); + expect(readme).not.toContain('until a later `UpdateApps` succeeds'); + expect(readme).toMatch(/Older 0\.16 releases and the 1\.0 pre-releases\s+unmount the Application and drop the cached copy\./); + expect(readme).toMatch(/The next restart,\s+`UpdateApps` or reload of the Applications \(the other nodes of a cluster\s+reload them after `UpdateApps`\) downloads `resourceUrl` again and mounts the\s+bundle once that works\./); + expect(readme).toMatch(/On every version, a restart after the cached copy\s+has expired/); + expect(readme).toMatch(/check that `resourceUrl` downloads before running\s+`UpdateApps`/); }); }); diff --git a/lib/__tests__/wopi-file-routes.test.ts b/lib/__tests__/wopi-file-routes.test.ts index c2eaf9989..a9b28352d 100644 --- a/lib/__tests__/wopi-file-routes.test.ts +++ b/lib/__tests__/wopi-file-routes.test.ts @@ -36,7 +36,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getWopiActions } from '@/lib/wopi/discovery'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api'; -import { updateFileNodeBlob } from '@/lib/wopi/files'; +import { shareeSavesNeedRename, updateFileNodeBlob } from '@/lib/wopi/files'; import { mintWopiToken, wopiDocumentId } from '@/lib/wopi/token'; const ORIGIN = 'https://webmail.example.com'; @@ -69,8 +69,14 @@ type MethodCall = [string, Record, string]; let version: '0.16' | '1.0'; let drives: Record>; +// Accounts the user is a member of: their own, and a group drive if a test adds one. +let memberOf: Set; let sent: MethodCall[]; +const filenodeAccount = (depth: number | null = version === '1.0' ? 64 : null) => ({ + accountCapabilities: { 'urn:ietf:params:jmap:filenode': { maxFileNodeDepth: depth, maxSizeFileNodeName: 255 } }, +}); + const node = (id: string, rights = ALL_RIGHTS): FakeNode => ({ id, parentId: null, name: 'Report.docx', type: DOCX, blobId: `G-old-${id}`, size: 10, modified: LOADED, myRights: rights, }); @@ -78,9 +84,10 @@ const node = (id: string, rights = ALL_RIGHTS): FakeNode => ({ /** * FileNode/get and FileNode/set the way Stalwart answers them. A blob can * only be used by members of the account it was uploaded into (Stalwart's - * `has_access_blob`, the same on 0.16 and 1.0); the user is a member of - * their own account only. 1.0 stamps `modified` on a content update only - * when the patch asks for it and echoes it in `updated`; 0.16.6+ always + * `has_access_blob`, the same on 0.16 and 1.0). A sharee needs + * mayModifyContent for a content update on 1.0, and mayRename (the Modify + * right) for any update on 0.16. 1.0 stamps `modified` on a content update + * only when the patch asks for it and echoes it in `updated`; 0.16.6+ always * bump it and answer `updated: {id: null}`. */ function fakeJmapServer() { @@ -99,12 +106,21 @@ function fakeJmapServer() { if (method === 'FileNode/set') { const updated: Record = {}; const notUpdated: Record = {}; + const sharee = !memberOf.has(args.accountId as string); for (const [id, patch] of Object.entries(args.update as Record)) { - if (!patch.blobId.startsWith(`G-${OWN}-`)) { + if (!memberOf.has(patch.blobId.split('-')[1])) { notUpdated[id] = { type: 'forbidden', description: `You do not have access to blobId ${patch.blobId}.` }; continue; } const target = drive[id]; + if (sharee && version === '0.16' && !target.myRights.mayRename) { + notUpdated[id] = { type: 'forbidden', description: 'You are not allowed to modify this file node.' }; + continue; + } + if (sharee && version === '1.0' && !target.myRights.mayModifyContent) { + notUpdated[id] = { type: 'forbidden', description: 'You are not allowed to modify the content of this file node.' }; + continue; + } target.blobId = patch.blobId; target.size = 5; if (version === '1.0') { @@ -167,6 +183,7 @@ beforeEach(() => { vi.clearAllMocks(); version = '1.0'; drives = { [OWN]: { f1: node('f1') }, [OWNER]: {} }; + memberOf = new Set([OWN]); sent = []; (getStalwartCredentials as Mock).mockResolvedValue({ serverUrl: SERVER, @@ -177,12 +194,13 @@ beforeEach(() => { hasSessionCookie: true, }); (getWopiActions as Mock).mockResolvedValue({ edit: { docx: OFFICE }, view: {} }); - (fetchJmapSession as Mock).mockResolvedValue({ + (fetchJmapSession as Mock).mockImplementation(async () => ({ primaryAccounts: { 'urn:ietf:params:jmap:mail': OWN, 'urn:ietf:params:jmap:filenode': OWN }, - accounts: { [OWN]: {}, [OWNER]: {} }, + // 1.0 advertises maxFileNodeDepth 64, 0.16 null, on the shared account too. + accounts: { [OWN]: filenodeAccount(), [OWNER]: filenodeAccount() }, downloadUrl: 'https://public.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}', uploadUrl: 'https://public.example.com/jmap/upload/{accountId}/', - }); + })); (fetchJmapServer as Mock).mockImplementation(async (url: string, init?: RequestInit) => { if (init?.method === 'POST') { const account = decodeURIComponent(new URL(url).pathname.split('/')[3]); @@ -286,16 +304,66 @@ describe('WOPI for a node in a shared drive', () => { expect(uploads()).toEqual([]); }); - it('keeps uploading into the document\'s account for tokens minted before the upload account existed', async () => { - const payload = { - serverUrl: SERVER, authHeader: 'Basic dXNlcjpwYXNz', username: 'user@example.com', - accountId: OWN, fileId: 'f1', kind: 'file' as const, canWrite: true, origin: ORIGIN, - }; - const documentId = wopiDocumentId(payload); - const { token } = mintWopiToken(payload); + // A share with the content right but not the rename right. + const CONTENT_ONLY = { ...ALL_RIGHTS, mayRename: false, mayAddChildren: false, mayDelete: false, mayShare: false }; - const res = await save({ documentId, token: `access_token=${encodeURIComponent(token)}` }); + it('opens read-only on 0.16 for a sharee who may change the content but not rename, since 0.16 refuses that save', async () => { + version = '0.16'; + drives[OWNER].n5.myRights = CONTENT_ONLY; + const { data } = await launchFile({ fileId: 'n5', accountId: OWNER }); + expect(data.readOnly).toBe(true); + + // What an editable token would have run into on every save. + const editable = mintEditableToken(OWNER, 'n5', OWN); + expect((await save(editable)).status).toBe(500); + expect(setCalls()).toEqual([{ accountId: OWNER, update: { n5: { blobId: `G-${OWN}-1`, modified: null } } }]); + }); + + it('opens editable on Stalwart 1.0 for a sharee with the content right alone', async () => { + drives[OWNER].n5.myRights = CONTENT_ONLY; + const { data } = await launchFile({ fileId: 'n5', accountId: OWNER }); + expect(data.readOnly).toBe(false); + expect(await save(editorCall(data))).toEqual({ status: 200, data: { LastModifiedTime: SAVED } }); + }); + + it('opens editable on 0.16 once the sharee may rename too', async () => { + version = '0.16'; + drives[OWNER].n5.myRights = { ...CONTENT_ONLY, mayRename: true }; + const { data } = await launchFile({ fileId: 'n5', accountId: OWNER }); + expect(data.readOnly).toBe(false); + expect(await save(editorCall(data))).toEqual({ status: 200, data: { LastModifiedTime: SAVED } }); + }); + + it('keeps uploading into the document\'s account for tokens minted before the upload account existed', async () => { + // A group drive the user belongs to, so a blob uploaded there is theirs to use. + memberOf.add(OWNER); + const res = await save(mintEditableToken(OWNER, 'n5')); expect(res.status).toBe(200); - expect(uploads()).toEqual([`${SERVER}/jmap/upload/${OWN}/`]); + expect(uploads()).toEqual([`${SERVER}/jmap/upload/${OWNER}/`]); + expect(setCalls()).toEqual([{ accountId: OWNER, update: { n5: { blobId: `G-${OWNER}-1`, modified: null } } }]); }); }); + +describe('shareeSavesNeedRename', () => { + it('asks a sharee for mayRename on 0.16 only, told apart by maxFileNodeDepth', () => { + const session = (shared: object) => ({ accounts: { [OWN]: filenodeAccount(64), [OWNER]: shared } }); + expect(shareeSavesNeedRename({ accounts: { [OWN]: filenodeAccount(null), [OWNER]: filenodeAccount(null) } }, OWNER, OWN)).toBe(true); + expect(shareeSavesNeedRename(session(filenodeAccount(64)), OWNER, OWN)).toBe(false); + // A shared account that lists no filenode capability goes by the user's own. + expect(shareeSavesNeedRename(session({}), OWNER, OWN)).toBe(false); + // Without a session, the stricter rule: better read-only than an editor that can't save. + expect(shareeSavesNeedRename(null, OWNER, OWN)).toBe(true); + }); +}); + +/** An editable token; without `uploadAccountId` as launches minted before it existed. */ +function mintEditableToken(accountId: string, fileId: string, uploadAccountId?: string) { + const payload = { + serverUrl: SERVER, authHeader: 'Basic dXNlcjpwYXNz', username: 'user@example.com', + accountId, fileId, kind: 'file' as const, canWrite: true, origin: ORIGIN, + ...(uploadAccountId ? { uploadAccountId } : {}), + }; + const documentId = wopiDocumentId(payload); + const { token } = mintWopiToken(payload); + return { documentId, token: `access_token=${encodeURIComponent(token)}` }; +} diff --git a/lib/deep-link-handoff.ts b/lib/deep-link-handoff.ts index a59df3a2a..a0fea45af 100644 --- a/lib/deep-link-handoff.ts +++ b/lib/deep-link-handoff.ts @@ -52,6 +52,19 @@ export function setPendingDeepLink(surface: AppSurface, segments: string[], sear pending.set(surface, { segments, search }); } +/** + * Hands a link to a surface that is mounted and listening, and says whether + * one was. Unlike setPendingDeepLink it never parks the link: without a + * listener the caller navigates instead, and the surface reads the link from + * the route when it mounts. + */ +export function deliverDeepLink(surface: AppSurface, segments: string[], search?: string): boolean { + const live = listeners.get(surface); + if (!live || live.size === 0) return false; + setPendingDeepLink(surface, segments, search); + return true; +} + /** Returns the parked link once, then forgets it. */ export function consumePendingDeepLinkEntry(surface: AppSurface): PendingDeepLink | null { const entry = pending.get(surface); @@ -67,10 +80,11 @@ export function consumePendingDeepLink(surface: AppSurface): string[] | null { /** * Live delivery for an already-mounted surface (the Pro shell keeps surfaces - * mounted for the whole session). Returns the unsubscribe function. Only the - * embedded (Pro) instance of a surface should subscribe - during a cold-load - * redirect the standard instance briefly renders too and must not steal the - * link parked for the Pro instance that mounts right after. + * mounted for the whole session). Returns the unsubscribe function. While the + * Pro interface is on, only the embedded (Pro) instance of a surface should + * subscribe - during a cold-load redirect the standard instance briefly + * renders too and must not steal the link parked for the Pro instance that + * mounts right after. */ export function subscribePendingDeepLink( surface: AppSurface, diff --git a/lib/deep-links.ts b/lib/deep-links.ts index 9ed61b1ea..71a6e925f 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -380,6 +380,12 @@ export interface FilesDeepLink { path: string; /** File name to preview once the folder has loaded, if the link named one. */ preview?: string; + /** + * A node of a drive shared with the user, or a page of such a drive, by the + * id the Files store lists it under (":"). It has no path under + * the user's own root; the Files app opens where it is listed instead. + */ + shared?: string; } export function buildFilesPath(currentPath: string, preview?: string | null): string { @@ -388,14 +394,25 @@ export function buildFilesPath(currentPath: string, preview?: string | null): st return preview ? `${base}?preview=${encodeURIComponent(preview)}` : base; } +/** The query of a link into a shared drive (see FilesDeepLink.shared), leading `?` included. */ +export function sharedFilesSearch(id: string, preview?: string | null): string { + const query = `?shared=${encodeURIComponent(id)}`; + return preview ? `${query}&preview=${encodeURIComponent(preview)}` : query; +} + +export function buildSharedFilesPath(id: string, preview?: string | null): string { + return `/files${sharedFilesSearch(id, preview)}`; +} + export function parseFilesPath( segments: string[] = [], search?: URLSearchParams, ): FilesDeepLink | null { const parts = segments.map(decodeSegment).filter(Boolean); const preview = search?.get('preview') ?? undefined; - if (parts.length === 0 && !preview) return null; - return { path: `/${parts.join('/')}`, preview }; + const shared = search?.get('shared') || undefined; + if (parts.length === 0 && !preview && !shared) return null; + return { path: `/${parts.join('/')}`, preview, ...(shared ? { shared } : {}) }; } // --------------------------------------------------------------------------- diff --git a/lib/file-errors.ts b/lib/file-errors.ts index 5050ec723..47231ba55 100644 --- a/lib/file-errors.ts +++ b/lib/file-errors.ts @@ -1,4 +1,4 @@ -import { jmapError, jmapErrorProperties, jmapErrorType } from '@/lib/jmap/jmap-error'; +import { jmapError, jmapErrorProperties, jmapErrorType, type JmapError } from '@/lib/jmap/jmap-error'; /** * Why a FileNode write failed, from the SetError the JMAP client threw @@ -14,6 +14,28 @@ export function folderDepthError(): Error { return jmapError(TOO_DEEP, { type: 'invalidProperties', properties: ['parentId'] }); } +type PartialCopyError = JmapError & { partialCopy?: boolean }; + +/** + * The error for a copy that stopped part-way and could not be taken back + * completely, so part of it is still there. It keeps the message and JMAP + * type of `cause`, which say why the copy stopped. + */ +export function partialCopyError(cause: unknown): Error { + const message = cause instanceof Error ? cause.message : String(cause); + const error: PartialCopyError = jmapError(message, { + type: jmapErrorType(cause), + properties: jmapErrorProperties(cause), + }); + error.partialCopy = true; + return error; +} + +/** Whether a failed copy left part of itself in place (see partialCopyError). */ +export function isPartialCopy(error: unknown): boolean { + return (error as PartialCopyError | null | undefined)?.partialCopy === true; +} + export function fileErrorKind(error: unknown): FileErrorKind | null { switch (jmapErrorType(error)) { // Stalwart 1.0, once an administrator limits files or folders per account. diff --git a/lib/global-search/__tests__/open-hit.test.ts b/lib/global-search/__tests__/open-hit.test.ts new file mode 100644 index 000000000..aa1284f0a --- /dev/null +++ b/lib/global-search/__tests__/open-hit.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileHit } from '../types'; +import type { FileNode } from '@/lib/jmap/types'; +import { appPath } from '@/lib/deep-links'; + +// Opening a Files hit: a node of a drive shared with the user has no path +// under the login's own root, so its link names it by id. + +const setPendingDeepLink = vi.fn(); +// Whether a Files page is open and listening (see deliverDeepLink). +const deliverDeepLink = vi.fn((..._args: unknown[]) => false); +vi.mock('@/lib/deep-link-handoff', () => ({ + setPendingDeepLink: (...args: unknown[]) => setPendingDeepLink(...args), + deliverDeepLink: (...args: unknown[]) => deliverDeepLink(...args), +})); + +function storeHook(state: object) { + const hook = (selector?: (s: object) => unknown) => (typeof selector === 'function' ? selector(state) : state); + hook.getState = () => state; + hook.setState = (patch: object) => Object.assign(state, patch); + hook.subscribe = () => () => {}; + return hook; +} + +const authState = { activeAccountId: 'login-a', getClientForAccount: () => undefined, switchAccount: vi.fn() }; +const fileState = { currentAccountId: 'login-a', initClient: vi.fn() }; +const tabState = { openTab: vi.fn(), openEmailTab: vi.fn() }; +vi.mock('@/stores/auth-store', () => ({ useAuthStore: storeHook(authState) })); +vi.mock('@/stores/file-store', () => ({ useFileStore: storeHook(fileState) })); +vi.mock('@/stores/pro-tab-store', () => ({ useProTabStore: storeHook(tabState) })); + +const { openHit } = await import('../open-hit'); + +const T = '2026-09-01T08:00:00Z'; + +function hit(node: Partial & Pick, folderPath: string, isFolder = false): FileHit { + const full: FileNode = { + parentId: null, type: isFolder ? '' : 'application/pdf', blobId: isFolder ? null : 'b1', size: 1, created: T, modified: T, ...node, + }; + return { + kind: 'files', + localAccountId: 'login-a', + jmapAccountId: full.accountId ?? 'me', + id: full.id.includes(':') ? full.id.split(':')[1] : full.id, + accountLabel: 'A', + title: full.name, + subtitle: folderPath, + date: T, + source: 'remote', + node: full, + folderPath, + isFolder, + }; +} + +// A file shared on its own (its folder isn't shared), a shared folder, and a +// file of the user's own drive. +const sharedFile = hit({ id: 'owner1:n5', name: 'Q3 plan.pdf', parentId: 'owner1:d0', isShared: true, accountId: 'owner1' }, '/Documents'); +const sharedFolder = hit({ id: 'owner1:d1', name: 'Project X', isShared: true, accountId: 'owner1' }, '/', true); +const ownFile = hit({ id: 'f1', name: 'a.txt', parentId: 'd1' }, '/Docs'); + +beforeEach(() => { + vi.clearAllMocks(); + deliverDeepLink.mockReturnValue(false); +}); + +describe('openHit for Files', () => { + it('links a shared file by id, with the file to preview, in the standard layout', async () => { + const navigate = vi.fn(); + await openHit(sharedFile, { proShell: false, navigate }); + expect(navigate).toHaveBeenCalledWith(appPath('/files?shared=owner1%3An5&preview=Q3%20plan.pdf')); + + await openHit(sharedFolder, { proShell: false, navigate }); + expect(navigate).toHaveBeenLastCalledWith(appPath('/files?shared=owner1%3Ad1')); + }); + + it('keeps linking the user\'s own files by path', async () => { + const navigate = vi.fn(); + await openHit(ownFile, { proShell: false, navigate }); + expect(navigate).toHaveBeenCalledWith(appPath('/files/Docs?preview=a.txt')); + }); + + it('hands the shared link to the Files tab in the Pro shell', async () => { + await openHit(sharedFile, { proShell: true }); + expect(tabState.openTab).toHaveBeenCalledWith('files'); + expect(setPendingDeepLink).toHaveBeenCalledWith('files', [], '?shared=owner1%3An5&preview=Q3%20plan.pdf'); + }); + + it('hands the link to a Files page that is already open instead of navigating, in the standard layout', async () => { + // Next keeps the page mounted when only the query changes, and the page + // reads the route only when it mounts. + deliverDeepLink.mockReturnValue(true); + const navigate = vi.fn(); + await openHit(sharedFile, { proShell: false, navigate }); + expect(deliverDeepLink).toHaveBeenCalledWith('files', [], '?shared=owner1%3An5&preview=Q3%20plan.pdf'); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('navigates for a hit of another login, which has to switch logins first', async () => { + deliverDeepLink.mockReturnValue(true); + const navigate = vi.fn(); + await openHit({ ...sharedFile, localAccountId: 'login-b' }, { proShell: false, navigate }); + expect(authState.switchAccount).toHaveBeenCalledWith('login-b'); + expect(deliverDeepLink).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(appPath('/files?shared=owner1%3An5&preview=Q3%20plan.pdf')); + }); +}); diff --git a/lib/global-search/open-hit.ts b/lib/global-search/open-hit.ts index 5ebdc7267..14930a98f 100644 --- a/lib/global-search/open-hit.ts +++ b/lib/global-search/open-hit.ts @@ -1,10 +1,11 @@ -import { setPendingDeepLink } from '@/lib/deep-link-handoff'; +import { deliverDeepLink, setPendingDeepLink } from '@/lib/deep-link-handoff'; import { appPath, buildCalendarPath, buildContactsPath, buildFilesPath, buildMailPath, + sharedFilesSearch, } from '@/lib/deep-links'; import type { GlobalSearchHit } from '@/lib/global-search/types'; import { useAuthStore } from '@/stores/auth-store'; @@ -21,6 +22,12 @@ export interface OpenHitOptions { } function fileLinkFor(hit: Extract): { segments: string[]; search?: string } { + // A node of a drive shared with the user has no path under the login's own + // root: the link names it by the id the Files store lists it under, and the + // Files app opens the shared folder (or shared-files page) it is listed in. + if (hit.node.isShared) { + return { segments: [], search: sharedFilesSearch(hit.node.id, hit.isFolder ? null : hit.node.name) }; + } const folderSegments = hit.folderPath.split('/').filter(Boolean); if (hit.isFolder) return { segments: [...folderSegments, hit.node.name] }; return { segments: folderSegments, search: `?preview=${encodeURIComponent(hit.node.name)}` }; @@ -72,9 +79,7 @@ export async function openHit(hit: GlobalSearchHit, options: OpenHitOptions): Pr } } tabs.openTab('files'); - // A node in a shared drive has no path under the login's own root - - // land on the drive root instead of a folder that doesn't resolve. - const link = hit.node.isShared ? { segments: [] } : fileLinkFor(hit); + const link = fileLinkFor(hit); setPendingDeepLink('files', link.segments, link.search); return; } @@ -95,9 +100,13 @@ export async function openHit(hit: GlobalSearchHit, options: OpenHitOptions): Pr navigate?.(appPath(buildCalendarPath({ view: 'month', date: null, eventId: hit.id, accountId: hit.jmapAccountId }))); return; case 'files': { - const link = hit.node.isShared ? { segments: [] as string[], search: undefined } : fileLinkFor(hit); - const path = `/${link.segments.join('/')}`; - navigate?.(appPath(buildFilesPath(path, hit.isFolder || hit.node.isShared ? null : hit.node.name))); + const link = fileLinkFor(hit); + // A Files page that is already open reads the route only when it + // mounts, and Next keeps it mounted when a link changes only the query + // (a node of a shared drive): it takes the link directly instead. + if (isActiveLogin && deliverDeepLink('files', link.segments, link.search)) return; + const path = buildFilesPath(`/${link.segments.join('/')}`); + navigate?.(appPath(`${path}${link.search ?? ''}`)); return; } } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index eeee708c3..fee0e8852 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -62,6 +62,13 @@ export interface CalendarQueryOptions { signal?: AbortSignal; } +/** + * FileNode trees by account, shared by the copies of one paste so that each + * account is read once rather than once per pasted item (see copyFileNode). + * Start each paste with an empty map. + */ +export type FileNodeListings = Map>; + export interface IJMAPClient { // ── Connection lifecycle ────────────────────────────────────── connect(): Promise; @@ -541,7 +548,7 @@ export interface IJMAPClient { updateFileNode(id: string, updates: Partial>): Promise; updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }>; destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>; - copyFileNode(id: string, newName: string, parentId: string | null): Promise; + copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise; // ── S/MIME raw-email helpers ────────────────────────────────── importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index a0844cb95..93c350d5d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,7 +1,7 @@ import { generateUUID } from '@/lib/utils'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; +import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; @@ -23,7 +23,7 @@ import { isServerSetKeyword } from "./server-keywords"; export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; -import { folderDepthError } from "@/lib/file-errors"; +import { folderDepthError, partialCopyError } from "@/lib/file-errors"; import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type"; import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; @@ -8876,12 +8876,22 @@ export class JMAPClient implements IJMAPClient { * drives. Blob/copy reserved the content to the target account, which only * that account's members may use: copying into someone else's shared drive * failed with it. + * + * `listings` lets the copies of one paste share the trees they read. */ - async copyFileNode(id: string, newName: string, parentId: string | null): Promise { + async copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise { const source = this.resolveFileNodeId(id); const target = this.resolveFileNodeId(parentId); + const listing = (accountId: string): Promise => { + let nodes = listings?.get(accountId); + if (!nodes) { + nodes = this.fetchAllFileNodes(accountId); + listings?.set(accountId, nodes); + } + return nodes; + }; - const tree = await this.fetchAllFileNodes(source.accountId); + const tree = await listing(source.accountId); const original = tree.find(n => n.id === source.id); if (!original) throw new Error('File node not found'); // Symlinks (Stalwart 1.0) are left out: there is no content to copy, and @@ -8920,43 +8930,84 @@ export class JMAPClient implements IJMAPClient { 1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0); const targetTree = target.accountId === source.accountId ? tree - : await this.fetchAllFileNodes(target.accountId).catch(() => null); + : await listing(target.accountId).catch(() => null); if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) { throw folderDepthError(); } } - const createCopy = (node: FileNode, name: string, parent: string | null): Promise => { + // The ids this copy creates, by level below the copy (0 is the copy + // itself), so that a failure takes back exactly those. + const made: string[][] = []; + const createCopy = async (node: FileNode, name: string, parent: string | null, level: number): Promise => { const props: Record = { name }; if (parent !== null) props.parentId = parent; if (!isFolderNode(node)) { props.type = this.fileNodeType(target.accountId, node.type); props.blobId = node.blobId; } - return this.createFileNodeIn(target.accountId, props, 'copy'); + const created = await this.createFileNodeIn(target.accountId, props, 'copy'); + (made[level] ??= []).push(created.id); + return created; }; - const copyChildren = async (entry: CopyEntry, parent: string): Promise => { + const copyChildren = async (entry: CopyEntry, parent: string, level: number): Promise => { for (const child of entry.children) { - const created = await createCopy(child.node, child.node.name, parent); - await copyChildren(child, created.id); + const created = await createCopy(child.node, child.node.name, parent, level); + await copyChildren(child, created.id, level + 1); } }; - const root = await createCopy(plan.node, newName, target.id); + const root = await createCopy(plan.node, newName, target.id, 0); const copy = this.namespaceFileNode(root, target.accountId); try { - await copyChildren(plan, root.id); + await copyChildren(plan, root.id, 1); } catch (error) { - // Take the half-made copy away again (its children go with it) and - // report what stopped it. - await this.destroyFileNodes([copy.id]).catch((cleanup) => { - console.warn('[Files] Could not remove a partly created copy:', cleanup instanceof Error ? cleanup.message : cleanup); - }); + // Take the half-made copy away again and report what stopped it. A + // large copy runs for minutes: whatever someone put into its folders + // meanwhile stays, and so do the folders holding it. + const kept = await this.destroyFileNodesBottomUp(target.accountId, made); + if (kept > 0) { + console.warn(`[Files] ${kept} node(s) of a failed copy were left in place`); + throw partialCopyError(error); + } throw error; } return copy; } + /** + * Destroy nodes of one account level by level, the deepest first, without + * onDestroyRemoveChildren, so a folder that holds anything else is refused + * (`nodeHasChildren`) and stays. Stalwart 0.16 checks a destroy against the + * tree as it was before the call and refuses a folder whose children go in + * the same call (`willDestroy`), hence one call per level. Returns how many + * of the nodes are still there. + */ + private async destroyFileNodesBottomUp(accountId: string, levels: string[][]): Promise { + let kept = 0; + for (let level = levels.length - 1; level >= 0; level--) { + for (const batch of batched(levels[level] ?? [], this.getMaxObjectsInSet())) { + try { + const response = await this.request( + [["FileNode/set", { accountId, destroy: batch }, "fns0"]], + this.fileUsing(), + ); + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + kept += batch.length; + continue; + } + const refused = Object.values(result[1].notDestroyed || {}) as Array<{ type?: string } | null>; + // Gone already is as good as destroyed. + kept += refused.filter(err => err?.type !== "notFound").length; + } catch { + kept += batch.length; + } + } + } + return kept; + } + async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise { const blob = await this.fetchBlob(blobId, name, type, accountId); const blobUrl = URL.createObjectURL(blob); diff --git a/lib/wopi/files.ts b/lib/wopi/files.ts index dea58b8e7..8fba4fd54 100644 --- a/lib/wopi/files.ts +++ b/lib/wopi/files.ts @@ -1,4 +1,4 @@ -import { postJmap, fetchJmapSession, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; +import { postJmap, fetchJmapSession, rebaseApiUrl, type JmapSessionDocument } from '@/lib/stalwart/jmap-api'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error'; import type { FileNode } from '@/lib/jmap/types'; @@ -52,6 +52,26 @@ async function callJmap(ctx: WopiJmapContext, methodCalls: unknown[]): Promise { + const account = session?.accounts?.[id] as { accountCapabilities?: Record } | undefined; + return account?.accountCapabilities?.['urn:ietf:params:jmap:filenode'] as { maxFileNodeDepth?: unknown } | undefined; + }; + return typeof (filenode(accountId) ?? filenode(ownAccountId))?.maxFileNodeDepth !== 'number'; +} + export async function getWopiFileNode( ctx: WopiJmapContext, accountId: string, diff --git a/locales/ar/common.json b/locales/ar/common.json index e84dce944..37055b69f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "لا يقبل الخادم هذا الاسم.", "error_forbidden": "ليست لديك صلاحية للقيام بذلك هنا.", "error_too_large": "هذا الملف كبير جدًا بالنسبة للخادم.", + "error_partial_copy": "بقي جزء من النسخة في مكانه.", "create_folder_success": "تم إنشاء المجلد", "create_folder_error": "فشل إنشاء المجلد", "delete_success": "تم الحذف بنجاح", @@ -3435,6 +3436,7 @@ "shared": "مشترك", "shared_with_me": "مشترك معي", "shared_by": "تمت المشاركة بواسطة {name}", + "shared_files_from": "ملفات من {name}", "create_file_success": "تم إنشاء الملف", "create_file_error": "فشل إنشاء الملف", "favorites": "المفضّلة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 38912962d..e37866fb0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "El servidor no accepta aquest nom.", "error_forbidden": "No teniu permís per fer-ho aquí.", "error_too_large": "Aquest fitxer és massa gran per al servidor.", + "error_partial_copy": "Part de la còpia s'ha quedat al seu lloc.", "create_folder_success": "Carpeta creada", "create_folder_error": "No s'ha pogut crear la carpeta", "delete_success": "Suprimit correctament", @@ -3435,6 +3436,7 @@ "shared": "Compartit", "shared_with_me": "Compartit amb mi", "shared_by": "Compartit per {name}", + "shared_files_from": "Fitxers de {name}", "create_file_success": "Fitxer creat", "create_file_error": "No s'ha pogut crear el fitxer", "favorites": "Preferits", diff --git a/locales/cs/common.json b/locales/cs/common.json index 43322b71d..2804f221c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Server tento název nepřijímá.", "error_forbidden": "K této akci zde nemáte oprávnění.", "error_too_large": "Tento soubor je pro server příliš velký.", + "error_partial_copy": "Část kopie zůstala na místě.", "create_folder_success": "Složka byla vytvořena", "create_folder_error": "Vytvoření složky selhalo", "delete_success": "Úspěšně odstraněno", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Soubory od {name}", "create_file_success": "Soubor byl vytvořen", "create_file_error": "Vytvoření souboru selhalo", "favorites": "Oblíbené", diff --git a/locales/da/common.json b/locales/da/common.json index 970295f9b..3e82c3b26 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3394,6 +3394,7 @@ "error_invalid_name": "Serveren accepterer ikke dette navn.", "error_forbidden": "Du har ikke tilladelse til at gøre dette her.", "error_too_large": "Denne fil er for stor til serveren.", + "error_partial_copy": "En del af kopien blev liggende.", "create_folder_success": "Mappe oprettet", "create_folder_error": "Kunne ikke oprette mappe", "delete_success": "Slettet", @@ -3433,6 +3434,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Filer fra {name}", "create_file_success": "Fil oprettet", "create_file_error": "Kunne ikke oprette fil", "favorites": "Favoritter", diff --git a/locales/de/common.json b/locales/de/common.json index 35cfd77f0..6a3d399c7 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Der Server akzeptiert diesen Namen nicht.", "error_forbidden": "Dazu fehlt Ihnen hier die Berechtigung.", "error_too_large": "Diese Datei ist für den Server zu groß.", + "error_partial_copy": "Ein Teil der Kopie wurde nicht entfernt.", "create_folder_success": "Ordner erstellt", "create_folder_error": "Ordner konnte nicht erstellt werden", "delete_success": "Erfolgreich gelöscht", @@ -3387,6 +3388,7 @@ "shared": "Geteilt", "shared_with_me": "Mit mir geteilt", "shared_by": "Von {name} geteilt", + "shared_files_from": "Dateien von {name}", "create_file_success": "Datei erstellt", "create_file_error": "Datei konnte nicht erstellt werden", "favorites": "Favoriten", diff --git a/locales/en/common.json b/locales/en/common.json index 810ca4e56..050609907 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "The server does not accept this name.", "error_forbidden": "You do not have permission to do this here.", "error_too_large": "This file is too large for the server.", + "error_partial_copy": "Part of the copy was left in place.", "create_folder_success": "Folder created", "create_folder_error": "Failed to create folder", "delete_success": "Deleted successfully", @@ -3435,6 +3436,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Files from {name}", "create_file_success": "File created", "create_file_error": "Failed to create file", "favorites": "Favorites", diff --git a/locales/es/common.json b/locales/es/common.json index 55aba883f..7ec3f8488 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "El servidor no acepta este nombre.", "error_forbidden": "No tiene permiso para hacer esto aquí.", "error_too_large": "Este archivo es demasiado grande para el servidor.", + "error_partial_copy": "Una parte de la copia se ha quedado en su lugar.", "create_folder_success": "Carpeta creada", "create_folder_error": "Error al crear la carpeta", "delete_success": "Eliminado correctamente", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Archivos de {name}", "create_file_success": "Archivo creado", "create_file_error": "Error al crear el archivo", "favorites": "Favoritos", diff --git a/locales/fa/common.json b/locales/fa/common.json index 26d725e09..40f7b6b5d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "سرور این نام را نمی‌پذیرد.", "error_forbidden": "اجازهٔ انجام این کار را در اینجا ندارید.", "error_too_large": "این فایل برای سرور بیش از حد بزرگ است.", + "error_partial_copy": "بخشی از رونوشت در جای خود باقی ماند.", "create_folder_success": "پوشه ایجاد شد", "create_folder_error": "ایجاد پوشه ناموفق بود", "delete_success": "با موفقیت حذف شد", @@ -3435,6 +3436,7 @@ "shared": "اشتراکی", "shared_with_me": "با من به اشتراک گذاشته شده", "shared_by": "به اشتراک گذاشته شده توسط {name}", + "shared_files_from": "فایل‌های {name}", "create_file_success": "فایل ایجاد شد", "create_file_error": "ایجاد فایل ناموفق بود", "favorites": "مورد علاقه‌ها", diff --git a/locales/fr/common.json b/locales/fr/common.json index 075fc59a9..fd5ff45dc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Le serveur n'accepte pas ce nom.", "error_forbidden": "Vous n'avez pas l'autorisation de faire cela ici.", "error_too_large": "Ce fichier est trop volumineux pour le serveur.", + "error_partial_copy": "Une partie de la copie est restée en place.", "create_folder_success": "Dossier créé", "create_folder_error": "Échec de la création du dossier", "delete_success": "Supprimé avec succès", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Fichiers de {name}", "create_file_success": "Fichier créé", "create_file_error": "Échec de la création du fichier", "favorites": "Favoris", diff --git a/locales/he/common.json b/locales/he/common.json index 0f105b76a..50090c21a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3271,6 +3271,7 @@ "error_invalid_name": "השרת אינו מקבל את השם הזה.", "error_forbidden": "אין לך הרשאה לבצע פעולה זו כאן.", "error_too_large": "הקובץ הזה גדול מדי עבור השרת.", + "error_partial_copy": "חלק מההעתק נשאר במקומו.", "create_folder_success": "התיקיה נוצרה", "create_folder_error": "יצירת התיקיה נכשלה", "delete_success": "נמחק בהצלחה", @@ -3354,6 +3355,7 @@ "shared": "משותף", "shared_with_me": "משותף איתי", "shared_by": "משותף על ידי {name}", + "shared_files_from": "קבצים מאת {name}", "open_folder_tree": "פתח עץ תיקייה", "migration_title": "עדכון הקבצים שלך…", "migration_description": "ארגון תיקיות וקבצים לתוך המבנה הנכון שלהם. זה קורה רק פעם אחת." diff --git a/locales/hu/common.json b/locales/hu/common.json index 62e4b6ea3..d31b0c0c3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "A kiszolgáló nem fogadja el ezt a nevet.", "error_forbidden": "Ehhez itt nincs jogosultságod.", "error_too_large": "Ez a fájl túl nagy a kiszolgáló számára.", + "error_partial_copy": "A másolat egy része a helyén maradt.", "create_folder_success": "Mappa létrehozva", "create_folder_error": "Nem sikerült létrehozni a mappát", "delete_success": "Sikeresen törölve", @@ -3435,6 +3436,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "{name} fájljai", "create_file_success": "Fájl létrehozva", "create_file_error": "Nem sikerült létrehozni a fájlt", "favorites": "Kedvencek", diff --git a/locales/it/common.json b/locales/it/common.json index 07211c801..cf92275bc 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Il server non accetta questo nome.", "error_forbidden": "Non hai l'autorizzazione per farlo qui.", "error_too_large": "Questo file è troppo grande per il server.", + "error_partial_copy": "Una parte della copia è rimasta al suo posto.", "create_folder_success": "Cartella creata", "create_folder_error": "Creazione della cartella non riuscita", "delete_success": "Eliminato con successo", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "File di {name}", "create_file_success": "File creato", "create_file_error": "Creazione del file non riuscita", "favorites": "Preferiti", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3497b0abb..9e5e1412a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "サーバーはこの名前を受け付けません。", "error_forbidden": "ここでこの操作を行う権限がありません。", "error_too_large": "このファイルはサーバーには大きすぎます。", + "error_partial_copy": "コピーの一部は削除されずに残っています。", "create_folder_success": "フォルダーを作成しました", "create_folder_error": "フォルダーの作成に失敗しました", "delete_success": "正常に削除しました", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "{name} からのファイル", "create_file_success": "ファイルを作成しました", "create_file_error": "ファイルの作成に失敗しました", "favorites": "お気に入り", diff --git a/locales/ko/common.json b/locales/ko/common.json index ca83dc784..fc76baa40 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "서버에서 이 이름을 허용하지 않습니다.", "error_forbidden": "여기에서 이 작업을 수행할 권한이 없습니다.", "error_too_large": "이 파일은 서버에서 처리하기에 너무 큽니다.", + "error_partial_copy": "복사본의 일부가 그대로 남아 있어요.", "create_folder_success": "폴더가 만들어졌어요", "create_folder_error": "폴더를 만들지 못했어요", "delete_success": "성공적으로 삭제되었어요", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "{name}님의 파일", "create_file_success": "파일이 만들어졌어요", "create_file_error": "파일을 만들지 못했어요", "favorites": "즐겨찾기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3bc8723e3..7f0d6b1fa 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Serveris nepieņem šo nosaukumu.", "error_forbidden": "Jums šeit nav atļaujas to darīt.", "error_too_large": "Šis fails serverim ir pārāk liels.", + "error_partial_copy": "Daļa kopijas palika savā vietā.", "create_folder_success": "Mape izveidota", "create_folder_error": "Neizdevās izveidot mapi", "delete_success": "Sekmīgi izdzēsts", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Faili no {name}", "create_file_success": "Fails izveidots", "create_file_error": "Neizdevās izveidot failu", "favorites": "Izlase", diff --git a/locales/mn/common.json b/locales/mn/common.json index d84ab461f..b58a7a09d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "Сервер энэ нэрийг хүлээн авахгүй байна.", "error_forbidden": "Танд энд үүнийг хийх эрх байхгүй.", "error_too_large": "Энэ файл серверийн хувьд хэт том байна.", + "error_partial_copy": "Хуулбарын нэг хэсэг хэвээр үлдлээ.", "create_folder_success": "Хавтас үүсгэсэн", "create_folder_error": "Хавтас үүсгэж чадсангүй", "delete_success": "Амжилттай устгасан", @@ -3435,6 +3436,7 @@ "shared": "Хуваалцсан", "shared_with_me": "Надтай хуваалцсан", "shared_by": "{name}", + "shared_files_from": "{name} хуваалцсан файлууд", "create_file_success": "Файл үүсгэсэн", "create_file_error": "Файлыг үүсгэж чадсангүй", "favorites": "Дуртай зүйлс", diff --git a/locales/nb/common.json b/locales/nb/common.json index 411951670..666b5cf81 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "Serveren godtar ikke dette navnet.", "error_forbidden": "Du har ikke tillatelse til å gjøre dette her.", "error_too_large": "Denne filen er for stor for serveren.", + "error_partial_copy": "En del av kopien ble liggende igjen.", "create_folder_success": "Mappen er opprettet", "create_folder_error": "Kunne ikke opprette mappe", "delete_success": "Elementet er slettet", @@ -3435,6 +3436,7 @@ "shared": "Delt", "shared_with_me": "Delt med meg", "shared_by": "Delt av {name}", + "shared_files_from": "Filer fra {name}", "create_file_success": "Filen er opprettet", "create_file_error": "Kunne ikke opprette filen", "favorites": "Favoritter", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8bedc97e5..c27122bac 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "De server accepteert deze naam niet.", "error_forbidden": "U hebt hier geen toestemming voor.", "error_too_large": "Dit bestand is te groot voor de server.", + "error_partial_copy": "Een deel van de kopie is blijven staan.", "create_folder_success": "Map aangemaakt", "create_folder_error": "Map aanmaken mislukt", "delete_success": "Succesvol verwijderd", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Bestanden van {name}", "create_file_success": "Bestand aangemaakt", "create_file_error": "Bestand aanmaken mislukt", "favorites": "Favorieten", diff --git a/locales/pl/common.json b/locales/pl/common.json index a56bc8a7f..078793052 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Serwer nie akceptuje tej nazwy.", "error_forbidden": "Nie masz tutaj uprawnień do wykonania tej czynności.", "error_too_large": "Ten plik jest zbyt duży dla serwera.", + "error_partial_copy": "Część kopii pozostała na miejscu.", "create_folder_success": "Folder utworzono", "create_folder_error": "Nie udało się utworzyć folderu", "delete_success": "Usunięto pomyślnie", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Pliki od {name}", "create_file_success": "Plik utworzono", "create_file_error": "Nie udało się utworzyć pliku", "favorites": "Ulubione", diff --git a/locales/pt/common.json b/locales/pt/common.json index 378f91d3d..dc0f1e638 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "O servidor não aceita este nome.", "error_forbidden": "Não tem permissão para fazer isso aqui.", "error_too_large": "Este arquivo é grande demais para o servidor.", + "error_partial_copy": "Parte da cópia ficou no lugar.", "create_folder_success": "Pasta criada", "create_folder_error": "Falha ao criar a pasta", "delete_success": "Eliminado com sucesso", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Arquivos de {name}", "create_file_success": "Arquivo criado", "create_file_error": "Falha ao criar arquivo", "favorites": "Favoritos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1c9eec3b8..55d18c762 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "Serverul nu acceptă acest nume.", "error_forbidden": "Nu aveți permisiunea de a face acest lucru aici.", "error_too_large": "Acest fișier este prea mare pentru server.", + "error_partial_copy": "O parte din copie a rămas pe loc.", "create_folder_success": "Folder creat", "create_folder_error": "Nu s-a putut crea folderul", "delete_success": "Șters cu succes", @@ -3435,6 +3436,7 @@ "shared": "Partajat", "shared_with_me": "Partajat cu mine", "shared_by": "Distribuit de {name}", + "shared_files_from": "Fișiere de la {name}", "create_file_success": "Fișier creat", "create_file_error": "Nu s-a putut crea fișierul", "favorites": "Favorite", diff --git a/locales/ru/common.json b/locales/ru/common.json index 776cb918b..a85e8778a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Сервер не принимает это имя.", "error_forbidden": "У вас нет прав на это действие здесь.", "error_too_large": "Этот файл слишком большой для сервера.", + "error_partial_copy": "Часть копии осталась на месте.", "create_folder_success": "Папка создана", "create_folder_error": "Не удалось создать папку", "delete_success": "Успешно удалено", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Файлы от {name}", "create_file_success": "Файл создан", "create_file_error": "Не удалось создать файл", "favorites": "Избранное", diff --git a/locales/sk/common.json b/locales/sk/common.json index da3d8df18..450c5611e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "Server tento názov neprijíma.", "error_forbidden": "Na túto akciu tu nemáte oprávnenie.", "error_too_large": "Tento súbor je pre server príliš veľký.", + "error_partial_copy": "Časť kópie zostala na mieste.", "create_folder_success": "Priečinok bol vytvorený", "create_folder_error": "Vytvorenie priečinka zlyhalo", "delete_success": "Úspešne odstránené", @@ -3435,6 +3436,7 @@ "shared": "Zdieľané", "shared_with_me": "Zdieľané so mnou", "shared_by": "Zdieľané od {name}", + "shared_files_from": "Súbory od {name}", "create_file_success": "Súbor bol vytvorený", "create_file_error": "Vytvorenie súboru zlyhalo", "favorites": "Obľúbené", diff --git a/locales/tr/common.json b/locales/tr/common.json index 9589b874f..cb0741ad1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3394,6 +3394,7 @@ "error_invalid_name": "Sunucu bu adı kabul etmiyor.", "error_forbidden": "Burada bunu yapma izniniz yok.", "error_too_large": "Bu dosya sunucu için çok büyük.", + "error_partial_copy": "Kopyanın bir kısmı yerinde kaldı.", "create_folder_success": "Klasör oluşturuldu", "create_folder_error": "Klasör oluşturulamadı", "delete_success": "Başarıyla silindi", @@ -3433,6 +3434,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "{name} tarafından paylaşılan dosyalar", "create_file_success": "Dosya oluşturuldu", "create_file_error": "Dosya oluşturulamadı", "favorites": "Favoriler", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2520b0ea2..dc3f93f75 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "Сервер не приймає цю назву.", "error_forbidden": "У вас немає дозволу на цю дію тут.", "error_too_large": "Цей файл завеликий для сервера.", + "error_partial_copy": "Частина копії залишилася на місці.", "create_folder_success": "Папка створена", "create_folder_error": "Не вдалося створити папку", "delete_success": "Успішно видалено", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "Файли від {name}", "create_file_success": "Файл створено", "create_file_error": "Не вдалося створити файл", "favorites": "Вибране", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 935eb7110..69d54a12c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3396,6 +3396,7 @@ "error_invalid_name": "伺服器不接受此名稱。", "error_forbidden": "你沒有權限在此處執行此操作。", "error_too_large": "此檔案對伺服器來說過大。", + "error_partial_copy": "部分副本仍保留在原處。", "create_folder_success": "資料夾已建立", "create_folder_error": "建立資料夾失敗", "delete_success": "刪除成功", @@ -3435,6 +3436,7 @@ "shared": "已分享", "shared_with_me": "與我共用", "shared_by": "由 {name} 分享", + "shared_files_from": "來自 {name} 的檔案", "create_file_success": "檔案已建立", "create_file_error": "建立檔案失敗", "favorites": "我的最愛", diff --git a/locales/zh/common.json b/locales/zh/common.json index 1ee6279c1..a969d3216 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3348,6 +3348,7 @@ "error_invalid_name": "服务器不接受此名称。", "error_forbidden": "你无权在此处执行此操作。", "error_too_large": "此文件对服务器来说过大。", + "error_partial_copy": "部分副本被保留在原处。", "create_folder_success": "文件夹已创建", "create_folder_error": "创建文件夹失败", "delete_success": "删除成功", @@ -3387,6 +3388,7 @@ "shared": "Shared", "shared_with_me": "Shared with me", "shared_by": "Shared by {name}", + "shared_files_from": "来自 {name} 的文件", "create_file_success": "文件已创建", "create_file_error": "创建文件失败", "favorites": "收藏夹", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 622034616..a1265ab5b 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -600,8 +600,10 @@ ${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and or over JMAP as an administrator: \`x:Http/set\` with \`{"update": {"singleton": {"usePermissiveCors": true}}}\`. It applies once the settings are reloaded (the \`ReloadSettings\` action) or Stalwart - restarts. Stalwart 0.16 and later ignore the \`[http] permissive-cors\` key - of the old TOML configuration. + restarts. On the current 1.0 pre-releases \`ReloadSettings\` fails while the + stock spam-filter rules are loaded, so restart Stalwart there instead. + Stalwart 0.16 and later ignore the \`[http] permissive-cors\` key of the old + TOML configuration. A reverse-proxy rule works too if it allows your Lite origin with the \`Authorization\` and \`Content-Type\` headers on \`/.well-known/jmap\`, \`/jmap/*\`, @@ -1371,10 +1373,13 @@ update right away. What a failed download does depends on the Stalwart version. On 0.16.23 the running bundle stays online. Older 0.16 releases and the 1.0 pre-releases -unmount the Application until a later \`UpdateApps\` succeeds. On every -version, a restart after the cached copy has expired, with \`resourceUrl\` -unreachable, leaves the Application unmounted. So check that \`resourceUrl\` -downloads before running \`UpdateApps\` or restarting. +unmount the Application and drop the cached copy. The next restart, +\`UpdateApps\` or reload of the Applications (the other nodes of a cluster +reload them after \`UpdateApps\`) downloads \`resourceUrl\` again and mounts the +bundle once that works. On every version, a restart after the cached copy +has expired, with \`resourceUrl\` unreachable, leaves the Application +unmounted. So check that \`resourceUrl\` downloads before running +\`UpdateApps\` or restarting. Open tabs move to the new build on their next navigation (one full page load); a freshly opened tab gets it immediately. Nothing stays stuck on old diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index d7b2c5bd8..abf1509b5 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useFileStore, resourceServerRef } from '../file-store'; +import { useFileStore, resourceServerRef, sharedFilesLocation } from '../file-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode } from '@/lib/jmap/types'; import { jmapError } from '@/lib/jmap/jmap-error'; @@ -420,9 +420,9 @@ describe('file-store "Shared with me"', () => { const T = '2026-09-01T08:00:00Z'; // Another user's nodes as listAllFileNodesAcrossAccounts hands them over. - const shared = (id: string, name: string, parentId: string | null, blobId: string | null = null): FileNode => ({ - id: `owner:${id}`, parentId: parentId && `owner:${parentId}`, name, type: blobId ? 'text/plain' : '', blobId, size: blobId ? 3 : 0, - created: T, modified: T, myRights: READ, isShared: true, accountId: 'owner', accountName: 'Owner', + const shared = (id: string, name: string, parentId: string | null, blobId: string | null = null, owner = 'owner'): FileNode => ({ + id: `${owner}:${id}`, parentId: parentId && `${owner}:${parentId}`, name, type: blobId ? 'text/plain' : '', blobId, size: blobId ? 3 : 0, + created: T, modified: T, myRights: READ, isShared: true, accountId: owner, accountName: owner === 'owner' ? 'Owner' : 'Other', }); // Stalwart 1.0's ancestors of a shared node: no dates, no rights. const placeholder = (id: string, name: string, parentId: string | null): FileNode => ({ @@ -478,6 +478,149 @@ describe('file-store "Shared with me"', () => { await useFileStore.getState().navigate('owner:f', 'Shared'); expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['old.txt']); }); + + // Owner's drive on Stalwart 1.0: a file shared on its own below a folder + // that isn't shared, one at the top, and a shared folder; plus another + // owner's file. + const drive10 = () => [ + placeholder('docs', 'Documents', null), + shared('plan', 'plan.pdf', 'docs', 'b1'), + shared('notes', 'notes.txt', null, 'b2'), + shared('x', 'Project X', null), + shared('sub', 'Sub', 'x'), + shared('report', 'report.txt', 'sub', 'b3'), + shared('theirs', 'theirs.txt', null, 'b4', 'other'), + ]; + + it('lists files shared on their own on their owner\'s page, on Stalwart 1.0', async () => { + const client = withSharedDrive(drive10()); + useFileStore.getState().initClient(client); + + await useFileStore.getState().loadSharedRoots(); + expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['Project X', 'notes.txt', 'plan.pdf', 'theirs.txt']); + + // Opened from wherever the user was, the page sits right below Home. + useFileStore.setState({ pathStack: [{ id: null, name: '' }, { id: 'mine', name: 'Mine' }] }); + await useFileStore.getState().navigate(sharedFilesLocation('owner'), 'Files from Owner'); + const state = useFileStore.getState(); + expect(state.resources.map(r => r.name)).toEqual(['notes.txt', 'plan.pdf']); + expect(state.resources[0]).toMatchObject({ id: 'owner:notes', blobId: 'b2', isShared: true, ownerAccountId: 'owner' }); + expect(state.currentPath).toBe('/Files from Owner'); + expect(state.pathStack.map(s => s.id)).toEqual([null, 'owner:*']); + }); + + it('lists a file shared on its own on its owner\'s page on 0.16, which leaves the folder above it out', async () => { + useFileStore.getState().initClient(withSharedDrive([shared('plan', 'plan.pdf', 'unlisted', 'b1')])); + + await useFileStore.getState().loadSharedRoots(); + expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['plan.pdf']); + await useFileStore.getState().navigate(sharedFilesLocation('owner'), 'Files from Owner'); + expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['plan.pdf']); + }); + + it('numbers same-named files from different folders on a shared-files page, so each row can be opened', async () => { + const client = withSharedDrive([ + placeholder('a', 'A', null), + placeholder('b', 'B', null), + shared('p1', 'plan.pdf', 'a', 'b1'), + shared('p2', 'plan.pdf', 'b', 'b2'), + ]); + Object.assign(client, { fetchBlobAsObjectUrl: async (blobId: string) => `blob:${blobId}` }); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(sharedFilesLocation('owner'), 'Files from Owner'); + + const rows = useFileStore.getState().resources; + expect(rows.map(r => [r.name, r.serverName, r.id])).toEqual([ + ['plan.pdf', 'plan.pdf', 'owner:p1'], + ['plan (1).pdf', 'plan.pdf', 'owner:p2'], + ]); + // Actions find a row by its name. + expect(await useFileStore.getState().getImageUrl('plan (1).pdf')).toBe('blob:b2'); + }); + + it('refuses to add anything to a shared-files page', async () => { + const client = withSharedDrive(drive10()); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(sharedFilesLocation('owner'), 'Files from Owner'); + const store = useFileStore.getState(); + const refusal = (promise: Promise) => promise.then(() => null, (error: unknown) => fileErrorKind(error)); + + expect(await refusal(store.createDirectory('New'))).toBe('forbidden'); + expect(await refusal(store.createTextFile('a.txt'))).toBe('forbidden'); + expect(await refusal(store.uploadFiles([new File(['x'], 'a.txt')]))).toBe('forbidden'); + expect(await refusal(store.uploadFolder([new File(['x'], 'a.txt')]))).toBe('forbidden'); + expect(await refusal(store.duplicateResource('notes.txt'))).toBe('forbidden'); + useFileStore.setState({ clipboard: { mode: 'copy', ids: ['owner:plan'], names: ['plan.pdf'], serverNames: ['plan.pdf'], sourceParentId: null, sourcePath: '/' } }); + expect(await refusal(useFileStore.getState().pasteResources())).toBe('forbidden'); + expect(client._nodes()).toEqual([]); + }); + + it('opens a shared node where it is listed, on Stalwart 1.0 and 0.16', async () => { + useFileStore.getState().initClient(withSharedDrive(drive10())); + const name = (owner: string) => `Files from ${owner}`; + const open = async (id: string) => { + const opened = await useFileStore.getState().openSharedNode(id, name); + const state = useFileStore.getState(); + return { opened, parent: state.currentParentId, path: state.currentPath, listed: state.resources.map(r => r.name) }; + }; + + // A file in a shared folder: that folder, with the trail from the shared root. + expect(await open('owner:report')).toEqual({ opened: true, parent: 'owner:sub', path: '/Project X/Sub', listed: ['report.txt'] }); + // A folder opens itself. + expect(await open('owner:x')).toEqual({ opened: true, parent: 'owner:x', path: '/Project X', listed: ['Sub'] }); + // A file shared on its own, below a 1.0 placeholder or at the top: its + // owner's page. + expect(await open('owner:plan')).toEqual({ opened: true, parent: 'owner:*', path: '/Files from Owner', listed: ['notes.txt', 'plan.pdf'] }); + expect(await open('other:theirs')).toEqual({ opened: true, parent: 'other:*', path: '/Files from Other', listed: ['theirs.txt'] }); + expect(await open(sharedFilesLocation('owner'))).toMatchObject({ opened: true, parent: 'owner:*' }); + + // Gone, a placeholder, or not shared at all. + for (const id of ['owner:gone', 'owner:docs', sharedFilesLocation('nobody')]) { + expect(await useFileStore.getState().openSharedNode(id, name)).toBe(false); + } + expect(useFileStore.getState().currentParentId).toBe(sharedFilesLocation('owner')); + + // 0.16 leaves the unshared folder above the file out altogether. + useFileStore.getState().initClient(withSharedDrive([shared('plan', 'plan.pdf', 'unlisted', 'b1')])); + expect(await open('owner:plan')).toEqual({ opened: true, parent: 'owner:*', path: '/Files from Owner', listed: ['plan.pdf'] }); + }); +}); + +describe('file-store paste', () => { + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + selectedResources: new Set(), + clipboard: null, + lastAction: null, + }); + }); + + it('hands every copy of one paste the same listings, so each tree is read once', async () => { + const client = makeMockClient([dir('docs', 'Docs', null), file('a', 'a.txt', null), file('b', 'b.txt', null)]); + const copyFileNode = client.copyFileNode.bind(client); + const listingsSeen: unknown[] = []; + Object.assign(client, { + copyFileNode: async (id: string, name: string, parentId: string | null, listings?: unknown) => { + listingsSeen.push(listings); + return copyFileNode(id, name, parentId); + }, + }); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + + useFileStore.getState().copyResources(['a.txt', 'b.txt']); + await useFileStore.getState().navigate('docs', 'Docs'); + await useFileStore.getState().pasteResources(); + + expect(listingsSeen).toHaveLength(2); + expect(listingsSeen[0]).toBeInstanceOf(Map); + expect(listingsSeen[1]).toBe(listingsSeen[0]); + }); }); describe('file-store symlinks (Stalwart 1.0)', () => { @@ -522,12 +665,24 @@ describe('file-store symlinks (Stalwart 1.0)', () => { useFileStore.getState().copyResources(['a.txt', 'latest']); expect(useFileStore.getState().clipboard).toMatchObject({ mode: 'copy', ids: ['f'], names: ['a.txt'] }); - useFileStore.getState().copyResources(['latest']); - expect(useFileStore.getState().clipboard).toMatchObject({ ids: ['f'] }); await useFileStore.getState().duplicateResource('latest'); expect(client._nodes()).toHaveLength(2); }); + + it('empties the clipboard when only symlinks are copied, so an earlier cut is not pasted', async () => { + const client = makeMockClient([dir('docs', 'Docs', null), file('f', 'a.txt', null), link('l', 'latest', null)]); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + + useFileStore.getState().cutResources(['a.txt']); + useFileStore.getState().copyResources(['latest']); + expect(useFileStore.getState().clipboard).toBeNull(); + + await useFileStore.getState().navigate('docs', 'Docs'); + await useFileStore.getState().pasteResources(); + expect(client._nodes().find(n => n.id === 'f')?.parentId).toBeNull(); + }); }); describe('file-store upload batches', () => { diff --git a/stores/file-store.ts b/stores/file-store.ts index 8dc17ac8c..9808b1ae6 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -1,8 +1,9 @@ import { create } from 'zustand'; -import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNodeListings, IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; import { fileNodeKind } from '@/lib/jmap/filenode-kind'; import { fileNodeDepth } from '@/lib/jmap/filenode-depth'; +import { jmapError } from '@/lib/jmap/jmap-error'; import { folderDepthError } from '@/lib/file-errors'; export interface FileResource { @@ -132,6 +133,13 @@ interface FileState { shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; /** Load the top-level nodes shared with the user by other principals. */ loadSharedRoots: () => Promise; + /** + * Open a node of a drive shared with the user, by the id the listings give + * it (":"), or a shared-files page: a folder opens itself, + * anything else the folder it is listed in. `sharedFilesName` titles an + * owner's shared-files page. False when the node is gone or not shared. + */ + openSharedNode: (id: string, sharedFilesName: (ownerName: string) => string) => Promise; } const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']); @@ -179,6 +187,85 @@ function isCrossAccountId(id: string | null): boolean { return id != null && id.includes(':'); } +// Files shared with the user on their own, outside any folder shared with +// them, have no folder to be listed in. Each owner's are listed on one page, +// ":*": JMAP ids never contain "*", so the page can't be taken for a +// node, and like the owner's nodes it counts as cross-account. +const SHARED_FILES = ':*'; + +/** The page listing the files `accountId` shares with the user outside any shared folder. */ +export function sharedFilesLocation(accountId: string): string { + return `${accountId}${SHARED_FILES}`; +} + +function sharedFilesOwner(id: string | null): string | null { + return id !== null && id.endsWith(SHARED_FILES) ? id.slice(0, -SHARED_FILES.length) : null; +} + +/** + * Whether a listing id is in a drive shared with the user: one of its + * folders or a shared-files page. Those have no path under the user's own + * root, so links name them by id. + */ +export function isSharedLocation(id: string | null): id is string { + return isCrossAccountId(id); +} + +// A shared-files page lists files; nothing can be put into it. +function assertCanAddTo(parentId: string | null): void { + if (sharedFilesOwner(parentId) !== null) { + throw jmapError('Nothing can be added to a list of shared files', { type: 'forbidden' }); + } +} + +// The top of what other accounts share with the user: shared nodes whose +// parent the user can't open (none, or a folder not shared with them). +// Stalwart 1.0 also lists the owner's folders above a shared node +// (discoverableOnly); left in, the owner's top folder became the root and the +// shared folder sat somewhere below it. +function sharedRootsOf(nodes: FileNode[]): FileNode[] { + const visible = nodes.filter(n => !n.discoverableOnly); + const ids = new Set(visible.map(n => n.id)); + return visible.filter(n => n.isShared && (n.parentId == null || !ids.has(n.parentId))); +} + +/** + * Where a node of a shared drive is opened, as the breadcrumb trail from its + * shared root: a folder or shared-files page opens itself, anything else its + * folder, or its owner's shared-files page when that folder isn't shared + * with the user. A shared-files page has no name of its own (null); the + * caller words it. + */ +function findSharedPlace( + nodes: FileNode[], + id: string, +): { trail: { id: string; name: string | null }[]; ownerName: string } | null { + const pageOwner = sharedFilesOwner(id); + if (pageOwner !== null) { + const files = sharedRootsOf(nodes).filter(n => n.accountId === pageOwner && !isFolder(n)); + if (files.length === 0) return null; + return { trail: [{ id, name: null }], ownerName: files[0].accountName || pageOwner }; + } + + const visible = new Map(nodes.filter(n => !n.discoverableOnly).map(n => [n.id, n])); + const node = visible.get(id); + if (!node?.isShared || !node.accountId) return null; + const ownerName = node.accountName || node.accountId; + const folder = isFolder(node) ? node : node.parentId != null ? visible.get(node.parentId) : undefined; + if (!folder || !isFolder(folder)) { + return { trail: [{ id: sharedFilesLocation(node.accountId), name: null }], ownerName }; + } + const trail: { id: string; name: string | null }[] = []; + const seen = new Set(); + let at: FileNode | undefined = folder; + while (at && !seen.has(at.id)) { + seen.add(at.id); + trail.unshift({ id: at.id, name: at.name }); + at = at.parentId != null ? visible.get(at.parentId) : undefined; + } + return { trail, ownerName }; +} + /** * The account and server id behind a listed resource, for calls made outside * the JMAP client (the WOPI editor): a shared drive's node is listed as @@ -248,6 +335,18 @@ function getUniqueName(name: string, existingNames: Set): string { return `${base} (${counter})${ext}`; } +// The files on a shared-files page come from any of the owner's folders, so +// two may have the same name, while the Files view tells rows apart by name. +// Later ones are numbered the way uploads are; serverName keeps the real name. +function withUniqueNames(resources: FileResource[]): FileResource[] { + const used = new Set(); + return resources.map(r => { + const name = getUniqueName(r.name, used); + used.add(name); + return name === r.name ? r : { ...r, name }; + }); +} + function buildPathFromStack(stack: { id: string | null; name: string }[]): string { if (stack.length <= 1) return '/'; return '/' + stack.slice(1).map(s => s.name).join('/'); @@ -518,6 +617,9 @@ export const useFileStore = create((set, get) => ({ let newStack: { id: string | null; name: string }[]; if (parentId === null) { newStack = [{ id: null, name: '' }]; + } else if (sharedFilesOwner(parentId) !== null) { + // A shared-files page sits right below Home, whatever was open before. + newStack = [{ id: null, name: '' }, { id: parentId, name: name || parentId }]; } else { // Check if navigating to a parent in the stack const existingIdx = pathStack.findIndex(s => s.id === parentId); @@ -543,11 +645,14 @@ export const useFileStore = create((set, get) => ({ const allNodes = isCrossAccountId(parentId) ? await client.listAllFileNodesAcrossAccounts() : await client.listAllFileNodes(); - // The owner's folders on the way down to a shared node aren't shown - // (see loadSharedRoots); the user can do nothing in them. - const resources = sortResources( - childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource), - ); + const owner = sharedFilesOwner(parentId); + const resources = owner !== null + ? withUniqueNames(sortResources( + sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource), + )) + // The owner's folders on the way down to a shared node aren't shown + // (see sharedRootsOf); the user can do nothing in them. + : sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource)); // Prune recent files whose backing node no longer exists on the server const { recentFiles } = get(); @@ -617,6 +722,7 @@ export const useFileStore = create((set, get) => ({ createDirectory: async (name: string) => { const { client, currentParentId, refresh } = get(); if (!client) return; + assertCanAddTo(currentParentId); await client.createFileDirectory(name, currentParentId); await refresh(); @@ -625,6 +731,7 @@ export const useFileStore = create((set, get) => ({ uploadFile: async (file: File) => { const { client, currentParentId } = get(); if (!client) return; + assertCanAddTo(currentParentId); const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -650,6 +757,7 @@ export const useFileStore = create((set, get) => ({ const { client, currentParentId, resources } = get(); const report: UploadReport = { uploaded: 0, failed: [] }; if (!client) return report; + assertCanAddTo(currentParentId); const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -697,6 +805,7 @@ export const useFileStore = create((set, get) => ({ const { client, currentParentId } = get(); const report: UploadReport = { uploaded: 0, failed: [] }; if (!client || files.length === 0) return report; + assertCanAddTo(currentParentId); const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -897,6 +1006,7 @@ export const useFileStore = create((set, get) => ({ createTextFile: async (name: string) => { const { client, currentParentId, refresh } = get(); if (!client) return; + assertCanAddTo(currentParentId); const emptyBlob = new File([''], name, { type: 'text/plain' }); const { blobId } = await client.uploadBlob(emptyBlob); @@ -910,6 +1020,7 @@ export const useFileStore = create((set, get) => ({ const resource = resources.find(r => r.name === name); if (!resource || resource.isSymlink) return; + assertCanAddTo(currentParentId); const dotIdx = name.lastIndexOf('.'); const copyName = dotIdx > 0 @@ -976,7 +1087,12 @@ export const useFileStore = create((set, get) => ({ const picked = names .map(n => resources.find(r => r.name === n)) .filter((r): r is FileResource => !!r && !r.isSymlink); - if (picked.length === 0) return; + if (picked.length === 0) { + // Nothing to copy replaces the clipboard too, instead of leaving an + // earlier cut or copy for the next paste. + set({ clipboard: null }); + return; + } set({ clipboard: { mode: 'copy', @@ -992,8 +1108,11 @@ export const useFileStore = create((set, get) => ({ pasteResources: async () => { const { client, currentParentId, clipboard, refresh } = get(); if (!client || !clipboard) return; + assertCanAddTo(currentParentId); const entries: UndoAction['entries'] = []; + // Each copy reads the source and target trees; read them once per paste. + const listings: FileNodeListings = new Map(); for (let i = 0; i < clipboard.ids.length; i++) { const id = clipboard.ids[i]; @@ -1003,7 +1122,7 @@ export const useFileStore = create((set, get) => ({ await client.updateFileNode(id, { parentId: currentParentId }); entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); } else { - await client.copyFileNode(id, displayName, currentParentId); + await client.copyFileNode(id, displayName, currentParentId, listings); } } @@ -1117,21 +1236,30 @@ export const useFileStore = create((set, get) => ({ return; } try { - // Stalwart 1.0 also lists the owner's folders above a shared node - // (discoverableOnly); left in, the owner's top folder became the root - // and the shared folder sat somewhere below it. - const all = (await client.listAllFileNodesAcrossAccounts()).filter(n => !n.discoverableOnly); - const idSet = new Set(all.map(n => n.id)); - // A shared node is a "root" of the shared-with-me tree when its parent is - // not among the nodes the user can see (either null, or owned by a - // principal whose ancestor folders weren't shared). - const roots = all.filter(n => - n.isShared && (n.parentId == null || !idSet.has(n.parentId)), - ); + const roots = sharedRootsOf(await client.listAllFileNodesAcrossAccounts()); set({ sharedRoots: sortResources(roots.map(nodeToResource)) }); } catch (error) { console.error('[Files] Failed to load shared roots:', error); set({ sharedRoots: [] }); } }, + + openSharedNode: async (id: string, sharedFilesName: (ownerName: string) => string) => { + const { client, navigate } = get(); + if (!client) return false; + let nodes: FileNode[]; + try { + nodes = await client.listAllFileNodesAcrossAccounts(); + } catch { + return false; + } + const place = findSharedPlace(nodes, id); + if (!place) return false; + const trail = place.trail.map(step => ({ id: step.id, name: step.name ?? sharedFilesName(place.ownerName) })); + const target = trail[trail.length - 1]; + // The breadcrumb starts at the shared root, not below whatever was open. + set({ pathStack: [{ id: null, name: '' }, ...trail.slice(0, -1)] }); + await navigate(target.id, target.name); + return true; + }, }));