fix: reach directly shared files and keep others' files on a failed copy

Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
This commit is contained in:
Linus Rath
2026-09-25 22:16:19 +02:00
parent 5a553a315b
commit d9ffe8a76e
49 changed files with 1010 additions and 126 deletions
+13 -3
View File
@@ -2282,6 +2282,7 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult {
const updated: Record<string, unknown> = {}; const updated: Record<string, unknown> = {};
const notUpdated: Record<string, unknown> = {}; const notUpdated: Record<string, unknown> = {};
const destroyed: string[] = []; const destroyed: string[] = [];
const notDestroyed: Record<string, unknown> = {};
if (args.create) { if (args.create) {
for (const [tempId, props] of Object.entries(args.create as Record<string, Record<string, unknown>>)) { for (const [tempId, props] of Object.entries(args.create as Record<string, Record<string, unknown>>)) {
@@ -2336,9 +2337,17 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult {
} }
if (Array.isArray(args.destroy)) { if (Array.isArray(args.destroy)) {
// The client always sends onDestroyRemoveChildren: true, so a destroyed const toDestroy = new Set<string>();
// folder takes its subtree with it. for (const id of args.destroy as string[]) {
const toDestroy = new Set(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; let grew = true;
while (grew) { while (grew) {
grew = false; grew = false;
@@ -2366,6 +2375,7 @@ function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult {
updated, updated,
notUpdated, notUpdated,
destroyed, destroyed,
notDestroyed,
}, callId]; }, callId];
} }
+9 -4
View File
@@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; 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 { 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'; 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). // needs the user's own account, where PutFile uploads (uploadAccountId).
let accountId = str('accountId'); let accountId = str('accountId');
let ownAccountId = ''; let ownAccountId = '';
let session: JmapSessionDocument | null = null;
if (!accountId || !blobId) { 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'; const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
ownAccountId = ownAccountId =
session?.primaryAccounts?.[capability] || session?.primaryAccounts?.[capability] ||
@@ -90,7 +91,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'File not found' }, { status: 404 }); return NextResponse.json({ error: 'File not found' }, { status: 404 });
} }
name = node.name; 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 }; document = { kind: 'file', fileId };
} }
+44 -16
View File
@@ -10,8 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store"; import { useFileStore, isSharedLocation, resourceServerRef, type UploadReport } from "@/stores/file-store";
import { fileErrorKind } from "@/lib/file-errors"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; 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 { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle, Loader2 } from "@/components/icons"; import { AlertTriangle, Loader2 } from "@/components/icons";
import { isFilePreviewable } from "@/lib/file-preview"; 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 { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
@@ -108,6 +108,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
undoLastAction, undoLastAction,
lastAction, lastAction,
shareResource, shareResource,
openSharedNode,
} = useFileStore(); } = useFileStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -223,23 +224,40 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
return link; return link;
}, []); }, []);
// Pro shell only: this surface stays mounted for the whole session, so links // A node of a drive shared with the user has no path under the user's own
// arriving after mount are delivered live instead of being parked forever. // root, so its link names it by id and the store opens where it is listed.
// The bootstrap has already run by then - walk the drive directly and let // False when it is gone or no longer shared.
// the preview effect below claim `pendingPreviewRef` once the listing lands. const openSharedLink = useCallback(async (id: string): Promise<boolean> => {
// Embedded-only: during a cold-load redirect the standard instance renders if (await openSharedNode(id, (name) => t("shared_files_from", { name }))) return true;
// briefly and must not steal the link parked for the Pro one. 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); const navigateByPathRef = useRef(navigateByPath);
navigateByPathRef.current = navigateByPath; navigateByPathRef.current = navigateByPath;
useEffect(() => { useEffect(() => {
if (!isEmbedded) return; if (!isEmbedded && proInterfaceActive) return;
return subscribePendingDeepLink('files', (segments, search) => { return subscribePendingDeepLink('files', (segments, search) => {
const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search)); const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search));
if (!link) return; if (!link) return;
if (link.preview) pendingPreviewRef.current = link.preview; 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. // Check support and load the first listing after the client is initialized.
// One effect, run once: `checkSupport` publishes `supportsFiles` before it // 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 // listing the root first would strand the user there and rewrite the
// URL they arrived on. // URL they arrived on.
const link = takeFilesLink(); 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); else if (firstCheck) await navigate(null);
})(); })();
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]); }, [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 // What the server said went wrong, as the second line under an action's
// own error message; undefined when it said nothing specific. // 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)) { switch (fileErrorKind(err)) {
case "overQuota": return t("error_over_quota"); case "overQuota": return t("error_over_quota");
case "tooDeep": return t("error_too_deep"); case "tooDeep": return t("error_too_deep");
@@ -320,6 +339,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
default: return undefined; default: return undefined;
} }
}, [t]); }, [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 // An upload goes on past a file the server refuses; the refused ones are
// reported once it is done. // 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 // 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 // tab writes the address bar; the standard instance that renders while Pro
// takes over a route stays silent. // takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab(); 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( useDeepLinkUrl(
isEmbedded isEmbedded
? (isFocusedProTab ? filesLinkPath : null) ? (isFocusedProTab ? filesLinkPath : null)
+44 -4
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import {
File,
Folder, Folder,
FolderOpen, FolderOpen,
ChevronRight, ChevronRight,
@@ -11,7 +12,7 @@ import {
Share2, Share2,
} from "@/components/icons"; } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store"; import { useFileStore, sharedFilesLocation, type FileResource } from "@/stores/file-store";
interface FolderNode { interface FolderNode {
id: string; id: string;
@@ -132,6 +133,21 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
onNavigate(path, id); onNavigate(path, id);
}, [onNavigate]); }, [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<string, string>();
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 ( return (
<div <div
className={cn( className={cn(
@@ -186,14 +202,14 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
)) ))
)} )}
{/* Shared with me: folders another principal has shared with the user */} {/* Shared with me: what another principal has shared with the user */}
{sharedRoots.filter(r => r.isDirectory).length > 0 && ( {(sharedFolders.length > 0 || sharedFilePages.length > 0) && (
<div className="mt-2 pt-2 border-t border-border/60"> <div className="mt-2 pt-2 border-t border-border/60">
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5"> <div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Share2 className="w-3 h-3" /> <Share2 className="w-3 h-3" />
<span className="truncate">{t("shared_with_me")}</span> <span className="truncate">{t("shared_with_me")}</span>
</div> </div>
{sharedRoots.filter(r => r.isDirectory).map(r => { {sharedFolders.map(r => {
const path = `/${r.name}`; const path = `/${r.name}`;
const isSelected = currentPath === path; const isSelected = currentPath === path;
return ( return (
@@ -217,6 +233,30 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
</div> </div>
); );
})} })}
{sharedFilePages.map(page => {
const path = `/${page.name}`;
const isSelected = currentPath === path;
return (
<div
key={page.id}
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
)}
>
<button
onClick={() => handleFolderClick(path, page.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={t("shared_by", { name: page.ownerName })}
>
<File className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
<span className="truncate">{page.name}</span>
</button>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
+13
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
setPendingDeepLink, setPendingDeepLink,
deliverDeepLink,
consumePendingDeepLink, consumePendingDeepLink,
consumePendingDeepLinkEntry, consumePendingDeepLinkEntry,
subscribePendingDeepLink, subscribePendingDeepLink,
@@ -82,4 +83,16 @@ describe('deep-link handoff', () => {
expect(consumePendingDeepLinkEntry('files')).toBeNull(); expect(consumePendingDeepLinkEntry('files')).toBeNull();
unsubscribe(); 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();
});
}); });
+12
View File
@@ -11,6 +11,7 @@ import {
buildContactsPath, buildContactsPath,
parseContactsPath, parseContactsPath,
buildFilesPath, buildFilesPath,
buildSharedFilesPath,
parseFilesPath, parseFilesPath,
buildSettingsPath, buildSettingsPath,
parseSettingsPath, parseSettingsPath,
@@ -272,6 +273,17 @@ describe('files paths', () => {
expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf'))) expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf')))
.toEqual({ path: '/Documents', 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', () => { describe('settings paths', () => {
+17
View File
@@ -463,5 +463,22 @@ describe('dev-jmap mock server', () => {
expect(created.uploaded.size).toBe(5); expect(created.uploaded.size).toBe(5);
expect(created.copied.size).toBe(148480); 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<string, unknown>) => {
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]);
});
}); });
}); });
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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'; import { jmapError } from '../jmap/jmap-error';
// SetErrors as Stalwart words them (1.0: jmap/src/file/*.rs; 0.16.23: // 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'); 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', () => { it('has nothing to add for other errors', () => {
// The folder is gone, or a folder was moved into itself. // 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(); expect(fileErrorKind(setError('invalidProperties', 'Parent ID does not exist or is not a folder.', ['parentId']))).toBeNull();
+96 -12
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { JMAPClient } from '../jmap/client'; import { JMAPClient } from '../jmap/client';
import type { FileNodeListings } from '../jmap/client-interface';
import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error'; import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
import { fileErrorKind } from '../file-errors'; import { fileErrorKind, isPartialCopy } from '../file-errors';
type Call = [string, Record<string, unknown>, string]; type Call = [string, Record<string, unknown>, string];
@@ -14,6 +15,8 @@ interface FakeOptions {
capability?: Record<string, unknown>; capability?: Record<string, unknown>;
/** Null: "grp" lists no filenode capability, as a shared account may. */ /** Null: "grp" lists no filenode capability, as a shared account may. */
sharedCapability?: Record<string, unknown> | null; sharedCapability?: Record<string, unknown> | 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). // The rules Stalwart 1.0 publishes (0.16.23: the same with maxFileNodeDepth null).
@@ -45,6 +48,9 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
.mockReturnValue(['me', 'grp']); .mockReturnValue(['me', 'grp']);
const calls: Call[] = []; const calls: Call[] = [];
let seq = 0; 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<string, string | null>();
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request') vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
.mockImplementation(async (reqCalls: Call[]) => { .mockImplementation(async (reqCalls: Call[]) => {
const [method, args, id] = reqCalls[0]; const [method, args, id] = reqCalls[0];
@@ -57,9 +63,13 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
return { methodResponses: [['Blob/copy', { copied: Object.fromEntries(blobIds.map(b => [b, `${b}-copy`])) }, id]] }; return { methodResponses: [['Blob/copy', { copied: Object.fromEntries(blobIds.map(b => [b, `${b}-copy`])) }, id]] };
} }
if (method === 'FileNode/set' && args.create) { if (method === 'FileNode/set' && args.create) {
const refusal = options.refuseCreate?.((args.create as { n0: Record<string, unknown> }).n0); const props = (args.create as { n0: Record<string, unknown> }).n0;
const refusal = options.refuseCreate?.(props);
if (refusal) return { methodResponses: [['FileNode/set', { notCreated: { n0: refusal } }, id]] }; 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) { if (method === 'FileNode/set' && args.update) {
const ids = Object.keys(args.update as object); const ids = Object.keys(args.update as object);
@@ -71,15 +81,26 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
} }
if (method === 'FileNode/set' && args.destroy) { if (method === 'FileNode/set' && args.destroy) {
const ids = args.destroy as string[]; const ids = args.destroy as string[];
const refused = ids.filter(k => options.refuse?.(k)); const destroyed: string[] = [];
return { methodResponses: [['FileNode/set', { const notDestroyed: Record<string, unknown> = {};
destroyed: ids.filter(k => !refused.includes(k)), // Without onDestroyRemoveChildren, a folder that still holds anything
notDestroyed: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])), // is refused. 0.16 checks against the tree as it was before the call,
}, id]] }; // 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}`); 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); 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 }, { 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 () => { 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 }, { const { client, calls } = makeClient({ me: docs }, {
// 1.0 once an administrator has set maxFiles. // 1.0 once an administrator has set maxFiles.
@@ -241,8 +264,15 @@ describe('FileNode writes', () => {
: undefined, : undefined,
}); });
await expect(client.copyFileNode('d1', 'Docs', 'grp:d9')).rejects.toThrow('There are too many files'); const refused = await client.copyFileNode('d1', 'Docs', 'grp:d9').catch((error: unknown) => error);
expect(sets(calls).at(-1)).toEqual({ accountId: 'grp', destroy: ['new1'], onDestroyRemoveChildren: true }); 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 () => { 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.'); 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<string, unknown>) => 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 () => { 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'); await client.copyFileNode('src', 'src', 'target');
expect(sets(calls)).toHaveLength(3); 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);
});
}); });
+12 -5
View File
@@ -196,6 +196,9 @@ describe('lite build helpers', () => {
expect(readme).toContain('stalwart-cli update Http --field usePermissiveCors=true'); expect(readme).toContain('stalwart-cli update Http --field usePermissiveCors=true');
expect(readme).toContain('{"update": {"singleton": {"usePermissiveCors": true}}}'); expect(readme).toContain('{"update": {"singleton": {"usePermissiveCors": true}}}');
expect(readme).toContain('ReloadSettings'); 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).not.toContain('permissive-cors = true');
expect(readme).toContain('currently `https://m.example`'); 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' }); 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`).'); 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 // 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 // pre-releases unmount the Application and delete the cached zip, so any
// and an unreachable URL leaves it unmounted everywhere. // 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).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(/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).not.toContain('until a later `UpdateApps` succeeds');
expect(readme).toMatch(/On every\s+version, a restart after the cached copy has expired/); 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(/check that `resourceUrl`\s+downloads before running `UpdateApps`/); 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`/);
}); });
}); });
+85 -17
View File
@@ -36,7 +36,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getWopiActions } from '@/lib/wopi/discovery'; import { getWopiActions } from '@/lib/wopi/discovery';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api'; 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'; import { mintWopiToken, wopiDocumentId } from '@/lib/wopi/token';
const ORIGIN = 'https://webmail.example.com'; const ORIGIN = 'https://webmail.example.com';
@@ -69,8 +69,14 @@ type MethodCall = [string, Record<string, unknown>, string];
let version: '0.16' | '1.0'; let version: '0.16' | '1.0';
let drives: Record<string, Record<string, FakeNode>>; let drives: Record<string, Record<string, FakeNode>>;
// Accounts the user is a member of: their own, and a group drive if a test adds one.
let memberOf: Set<string>;
let sent: MethodCall[]; 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 => ({ 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, 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 * 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 * 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 * `has_access_blob`, the same on 0.16 and 1.0). A sharee needs
* their own account only. 1.0 stamps `modified` on a content update only * mayModifyContent for a content update on 1.0, and mayRename (the Modify
* when the patch asks for it and echoes it in `updated`; 0.16.6+ always * 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}`. * bump it and answer `updated: {id: null}`.
*/ */
function fakeJmapServer() { function fakeJmapServer() {
@@ -99,12 +106,21 @@ function fakeJmapServer() {
if (method === 'FileNode/set') { if (method === 'FileNode/set') {
const updated: Record<string, unknown> = {}; const updated: Record<string, unknown> = {};
const notUpdated: Record<string, unknown> = {}; const notUpdated: Record<string, unknown> = {};
const sharee = !memberOf.has(args.accountId as string);
for (const [id, patch] of Object.entries(args.update as Record<string, { blobId: string; modified?: null }>)) { for (const [id, patch] of Object.entries(args.update as Record<string, { blobId: string; modified?: null }>)) {
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}.` }; notUpdated[id] = { type: 'forbidden', description: `You do not have access to blobId ${patch.blobId}.` };
continue; continue;
} }
const target = drive[id]; 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.blobId = patch.blobId;
target.size = 5; target.size = 5;
if (version === '1.0') { if (version === '1.0') {
@@ -167,6 +183,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
version = '1.0'; version = '1.0';
drives = { [OWN]: { f1: node('f1') }, [OWNER]: {} }; drives = { [OWN]: { f1: node('f1') }, [OWNER]: {} };
memberOf = new Set([OWN]);
sent = []; sent = [];
(getStalwartCredentials as Mock).mockResolvedValue({ (getStalwartCredentials as Mock).mockResolvedValue({
serverUrl: SERVER, serverUrl: SERVER,
@@ -177,12 +194,13 @@ beforeEach(() => {
hasSessionCookie: true, hasSessionCookie: true,
}); });
(getWopiActions as Mock).mockResolvedValue({ edit: { docx: OFFICE }, view: {} }); (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 }, 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}', downloadUrl: 'https://public.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadUrl: 'https://public.example.com/jmap/upload/{accountId}/', uploadUrl: 'https://public.example.com/jmap/upload/{accountId}/',
}); }));
(fetchJmapServer as Mock).mockImplementation(async (url: string, init?: RequestInit) => { (fetchJmapServer as Mock).mockImplementation(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') { if (init?.method === 'POST') {
const account = decodeURIComponent(new URL(url).pathname.split('/')[3]); 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([]); expect(uploads()).toEqual([]);
}); });
it('keeps uploading into the document\'s account for tokens minted before the upload account existed', async () => { // A share with the content right but not the rename right.
const payload = { const CONTENT_ONLY = { ...ALL_RIGHTS, mayRename: false, mayAddChildren: false, mayDelete: false, mayShare: false };
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);
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(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)}` };
}
+18 -4
View File
@@ -52,6 +52,19 @@ export function setPendingDeepLink(surface: AppSurface, segments: string[], sear
pending.set(surface, { segments, search }); 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. */ /** Returns the parked link once, then forgets it. */
export function consumePendingDeepLinkEntry(surface: AppSurface): PendingDeepLink | null { export function consumePendingDeepLinkEntry(surface: AppSurface): PendingDeepLink | null {
const entry = pending.get(surface); 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 * Live delivery for an already-mounted surface (the Pro shell keeps surfaces
* mounted for the whole session). Returns the unsubscribe function. Only the * mounted for the whole session). Returns the unsubscribe function. While the
* embedded (Pro) instance of a surface should subscribe - during a cold-load * Pro interface is on, only the embedded (Pro) instance of a surface should
* redirect the standard instance briefly renders too and must not steal the * subscribe - during a cold-load redirect the standard instance briefly
* link parked for the Pro instance that mounts right after. * renders too and must not steal the link parked for the Pro instance that
* mounts right after.
*/ */
export function subscribePendingDeepLink( export function subscribePendingDeepLink(
surface: AppSurface, surface: AppSurface,
+19 -2
View File
@@ -380,6 +380,12 @@ export interface FilesDeepLink {
path: string; path: string;
/** File name to preview once the folder has loaded, if the link named one. */ /** File name to preview once the folder has loaded, if the link named one. */
preview?: string; 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 ("<owner>:<id>"). 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 { 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; 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( export function parseFilesPath(
segments: string[] = [], segments: string[] = [],
search?: URLSearchParams, search?: URLSearchParams,
): FilesDeepLink | null { ): FilesDeepLink | null {
const parts = segments.map(decodeSegment).filter(Boolean); const parts = segments.map(decodeSegment).filter(Boolean);
const preview = search?.get('preview') ?? undefined; const preview = search?.get('preview') ?? undefined;
if (parts.length === 0 && !preview) return null; const shared = search?.get('shared') || undefined;
return { path: `/${parts.join('/')}`, preview }; if (parts.length === 0 && !preview && !shared) return null;
return { path: `/${parts.join('/')}`, preview, ...(shared ? { shared } : {}) };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+23 -1
View File
@@ -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 * 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'] }); 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 { export function fileErrorKind(error: unknown): FileErrorKind | null {
switch (jmapErrorType(error)) { switch (jmapErrorType(error)) {
// Stalwart 1.0, once an administrator limits files or folders per account. // Stalwart 1.0, once an administrator limits files or folders per account.
@@ -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<FileNode> & Pick<FileNode, 'id' | 'name'>, 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'));
});
});
+16 -7
View File
@@ -1,10 +1,11 @@
import { setPendingDeepLink } from '@/lib/deep-link-handoff'; import { deliverDeepLink, setPendingDeepLink } from '@/lib/deep-link-handoff';
import { import {
appPath, appPath,
buildCalendarPath, buildCalendarPath,
buildContactsPath, buildContactsPath,
buildFilesPath, buildFilesPath,
buildMailPath, buildMailPath,
sharedFilesSearch,
} from '@/lib/deep-links'; } from '@/lib/deep-links';
import type { GlobalSearchHit } from '@/lib/global-search/types'; import type { GlobalSearchHit } from '@/lib/global-search/types';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -21,6 +22,12 @@ export interface OpenHitOptions {
} }
function fileLinkFor(hit: Extract<GlobalSearchHit, { kind: 'files' }>): { segments: string[]; search?: string } { function fileLinkFor(hit: Extract<GlobalSearchHit, { kind: 'files' }>): { 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); const folderSegments = hit.folderPath.split('/').filter(Boolean);
if (hit.isFolder) return { segments: [...folderSegments, hit.node.name] }; if (hit.isFolder) return { segments: [...folderSegments, hit.node.name] };
return { segments: folderSegments, search: `?preview=${encodeURIComponent(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'); tabs.openTab('files');
// A node in a shared drive has no path under the login's own root - const link = fileLinkFor(hit);
// land on the drive root instead of a folder that doesn't resolve.
const link = hit.node.isShared ? { segments: [] } : fileLinkFor(hit);
setPendingDeepLink('files', link.segments, link.search); setPendingDeepLink('files', link.segments, link.search);
return; 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 }))); navigate?.(appPath(buildCalendarPath({ view: 'month', date: null, eventId: hit.id, accountId: hit.jmapAccountId })));
return; return;
case 'files': { case 'files': {
const link = hit.node.isShared ? { segments: [] as string[], search: undefined } : fileLinkFor(hit); const link = fileLinkFor(hit);
const path = `/${link.segments.join('/')}`; // A Files page that is already open reads the route only when it
navigate?.(appPath(buildFilesPath(path, hit.isFolder || hit.node.isShared ? null : hit.node.name))); // 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; return;
} }
} }
+8 -1
View File
@@ -62,6 +62,13 @@ export interface CalendarQueryOptions {
signal?: AbortSignal; 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<string, Promise<FileNode[]>>;
export interface IJMAPClient { export interface IJMAPClient {
// ── Connection lifecycle ────────────────────────────────────── // ── Connection lifecycle ──────────────────────────────────────
connect(): Promise<void>; connect(): Promise<void>;
@@ -541,7 +548,7 @@ export interface IJMAPClient {
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>; updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>; updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>; destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>; copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode>;
// ── S/MIME raw-email helpers ────────────────────────────────── // ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>; importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
+68 -17
View File
@@ -1,7 +1,7 @@
import { generateUUID } from '@/lib/utils'; 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 { 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 { 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 { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
import { textConditions, textFilter } from "./search-utils"; import { textConditions, textFilter } from "./search-utils";
import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
@@ -23,7 +23,7 @@ import { isServerSetKeyword } from "./server-keywords";
export { SchedulingDeniedError }; export { SchedulingDeniedError };
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; 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 { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type";
import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone"; import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; 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 * 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 * that account's members may use: copying into someone else's shared drive
* failed with it. * 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<FileNode> { async copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode> {
const source = this.resolveFileNodeId(id); const source = this.resolveFileNodeId(id);
const target = this.resolveFileNodeId(parentId); const target = this.resolveFileNodeId(parentId);
const listing = (accountId: string): Promise<FileNode[]> => {
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); const original = tree.find(n => n.id === source.id);
if (!original) throw new Error('File node not found'); if (!original) throw new Error('File node not found');
// Symlinks (Stalwart 1.0) are left out: there is no content to copy, and // 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); 1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0);
const targetTree = target.accountId === source.accountId const targetTree = target.accountId === source.accountId
? tree ? tree
: await this.fetchAllFileNodes(target.accountId).catch(() => null); : await listing(target.accountId).catch(() => null);
if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) { if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) {
throw folderDepthError(); throw folderDepthError();
} }
} }
const createCopy = (node: FileNode, name: string, parent: string | null): Promise<FileNode> => { // 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<FileNode> => {
const props: Record<string, unknown> = { name }; const props: Record<string, unknown> = { name };
if (parent !== null) props.parentId = parent; if (parent !== null) props.parentId = parent;
if (!isFolderNode(node)) { if (!isFolderNode(node)) {
props.type = this.fileNodeType(target.accountId, node.type); props.type = this.fileNodeType(target.accountId, node.type);
props.blobId = node.blobId; 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<void> => { const copyChildren = async (entry: CopyEntry, parent: string, level: number): Promise<void> => {
for (const child of entry.children) { for (const child of entry.children) {
const created = await createCopy(child.node, child.node.name, parent); const created = await createCopy(child.node, child.node.name, parent, level);
await copyChildren(child, created.id); 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); const copy = this.namespaceFileNode(root, target.accountId);
try { try {
await copyChildren(plan, root.id); await copyChildren(plan, root.id, 1);
} catch (error) { } catch (error) {
// Take the half-made copy away again (its children go with it) and // Take the half-made copy away again and report what stopped it. A
// report what stopped it. // large copy runs for minutes: whatever someone put into its folders
await this.destroyFileNodes([copy.id]).catch((cleanup) => { // meanwhile stays, and so do the folders holding it.
console.warn('[Files] Could not remove a partly created copy:', cleanup instanceof Error ? cleanup.message : cleanup); 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; throw error;
} }
return copy; 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<number> {
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<void> { async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type, accountId); const blob = await this.fetchBlob(blobId, name, type, accountId);
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
+21 -1
View File
@@ -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 { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error'; import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error';
import type { FileNode } from '@/lib/jmap/types'; import type { FileNode } from '@/lib/jmap/types';
@@ -52,6 +52,26 @@ async function callJmap(ctx: WopiJmapContext, methodCalls: unknown[]): Promise<J
return json.methodResponses ?? []; return json.methodResponses ?? [];
} }
/**
* Whether saving into another account's node takes the Modify right
* (`mayRename`) on top of `mayModifyContent`. Stalwart 0.16 refuses any
* update a sharee makes without Modify in the node's own grants, which its
* `myRights` reports, content changes included; 1.0 asks for the content
* right alone. 1.0 advertises `maxFileNodeDepth` (64) and 0.16 null. A
* shared account without the capability goes by the user's own.
*/
export function shareeSavesNeedRename(
session: JmapSessionDocument | null,
accountId: string,
ownAccountId: string,
): boolean {
const filenode = (id: string) => {
const account = session?.accounts?.[id] as { accountCapabilities?: Record<string, unknown> } | 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( export async function getWopiFileNode(
ctx: WopiJmapContext, ctx: WopiJmapContext,
accountId: string, accountId: string,
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "لا يقبل الخادم هذا الاسم.", "error_invalid_name": "لا يقبل الخادم هذا الاسم.",
"error_forbidden": "ليست لديك صلاحية للقيام بذلك هنا.", "error_forbidden": "ليست لديك صلاحية للقيام بذلك هنا.",
"error_too_large": "هذا الملف كبير جدًا بالنسبة للخادم.", "error_too_large": "هذا الملف كبير جدًا بالنسبة للخادم.",
"error_partial_copy": "بقي جزء من النسخة في مكانه.",
"create_folder_success": "تم إنشاء المجلد", "create_folder_success": "تم إنشاء المجلد",
"create_folder_error": "فشل إنشاء المجلد", "create_folder_error": "فشل إنشاء المجلد",
"delete_success": "تم الحذف بنجاح", "delete_success": "تم الحذف بنجاح",
@@ -3435,6 +3436,7 @@
"shared": "مشترك", "shared": "مشترك",
"shared_with_me": "مشترك معي", "shared_with_me": "مشترك معي",
"shared_by": "تمت المشاركة بواسطة {name}", "shared_by": "تمت المشاركة بواسطة {name}",
"shared_files_from": "ملفات من {name}",
"create_file_success": "تم إنشاء الملف", "create_file_success": "تم إنشاء الملف",
"create_file_error": "فشل إنشاء الملف", "create_file_error": "فشل إنشاء الملف",
"favorites": "المفضّلة", "favorites": "المفضّلة",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "El servidor no accepta aquest nom.", "error_invalid_name": "El servidor no accepta aquest nom.",
"error_forbidden": "No teniu permís per fer-ho aquí.", "error_forbidden": "No teniu permís per fer-ho aquí.",
"error_too_large": "Aquest fitxer és massa gran per al servidor.", "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_success": "Carpeta creada",
"create_folder_error": "No s'ha pogut crear la carpeta", "create_folder_error": "No s'ha pogut crear la carpeta",
"delete_success": "Suprimit correctament", "delete_success": "Suprimit correctament",
@@ -3435,6 +3436,7 @@
"shared": "Compartit", "shared": "Compartit",
"shared_with_me": "Compartit amb mi", "shared_with_me": "Compartit amb mi",
"shared_by": "Compartit per {name}", "shared_by": "Compartit per {name}",
"shared_files_from": "Fitxers de {name}",
"create_file_success": "Fitxer creat", "create_file_success": "Fitxer creat",
"create_file_error": "No s'ha pogut crear el fitxer", "create_file_error": "No s'ha pogut crear el fitxer",
"favorites": "Preferits", "favorites": "Preferits",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Server tento název nepřijímá.", "error_invalid_name": "Server tento název nepřijímá.",
"error_forbidden": "K této akci zde nemáte oprávnění.", "error_forbidden": "K této akci zde nemáte oprávnění.",
"error_too_large": "Tento soubor je pro server příliš velký.", "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_success": "Složka byla vytvořena",
"create_folder_error": "Vytvoření složky selhalo", "create_folder_error": "Vytvoření složky selhalo",
"delete_success": "Úspěšně odstraněno", "delete_success": "Úspěšně odstraněno",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Soubory od {name}",
"create_file_success": "Soubor byl vytvořen", "create_file_success": "Soubor byl vytvořen",
"create_file_error": "Vytvoření souboru selhalo", "create_file_error": "Vytvoření souboru selhalo",
"favorites": "Oblíbené", "favorites": "Oblíbené",
+2
View File
@@ -3394,6 +3394,7 @@
"error_invalid_name": "Serveren accepterer ikke dette navn.", "error_invalid_name": "Serveren accepterer ikke dette navn.",
"error_forbidden": "Du har ikke tilladelse til at gøre dette her.", "error_forbidden": "Du har ikke tilladelse til at gøre dette her.",
"error_too_large": "Denne fil er for stor til serveren.", "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_success": "Mappe oprettet",
"create_folder_error": "Kunne ikke oprette mappe", "create_folder_error": "Kunne ikke oprette mappe",
"delete_success": "Slettet", "delete_success": "Slettet",
@@ -3433,6 +3434,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Filer fra {name}",
"create_file_success": "Fil oprettet", "create_file_success": "Fil oprettet",
"create_file_error": "Kunne ikke oprette fil", "create_file_error": "Kunne ikke oprette fil",
"favorites": "Favoritter", "favorites": "Favoritter",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Der Server akzeptiert diesen Namen nicht.", "error_invalid_name": "Der Server akzeptiert diesen Namen nicht.",
"error_forbidden": "Dazu fehlt Ihnen hier die Berechtigung.", "error_forbidden": "Dazu fehlt Ihnen hier die Berechtigung.",
"error_too_large": "Diese Datei ist für den Server zu groß.", "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_success": "Ordner erstellt",
"create_folder_error": "Ordner konnte nicht erstellt werden", "create_folder_error": "Ordner konnte nicht erstellt werden",
"delete_success": "Erfolgreich gelöscht", "delete_success": "Erfolgreich gelöscht",
@@ -3387,6 +3388,7 @@
"shared": "Geteilt", "shared": "Geteilt",
"shared_with_me": "Mit mir geteilt", "shared_with_me": "Mit mir geteilt",
"shared_by": "Von {name} geteilt", "shared_by": "Von {name} geteilt",
"shared_files_from": "Dateien von {name}",
"create_file_success": "Datei erstellt", "create_file_success": "Datei erstellt",
"create_file_error": "Datei konnte nicht erstellt werden", "create_file_error": "Datei konnte nicht erstellt werden",
"favorites": "Favoriten", "favorites": "Favoriten",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "The server does not accept this name.", "error_invalid_name": "The server does not accept this name.",
"error_forbidden": "You do not have permission to do this here.", "error_forbidden": "You do not have permission to do this here.",
"error_too_large": "This file is too large for the server.", "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_success": "Folder created",
"create_folder_error": "Failed to create folder", "create_folder_error": "Failed to create folder",
"delete_success": "Deleted successfully", "delete_success": "Deleted successfully",
@@ -3435,6 +3436,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Files from {name}",
"create_file_success": "File created", "create_file_success": "File created",
"create_file_error": "Failed to create file", "create_file_error": "Failed to create file",
"favorites": "Favorites", "favorites": "Favorites",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "El servidor no acepta este nombre.", "error_invalid_name": "El servidor no acepta este nombre.",
"error_forbidden": "No tiene permiso para hacer esto aquí.", "error_forbidden": "No tiene permiso para hacer esto aquí.",
"error_too_large": "Este archivo es demasiado grande para el servidor.", "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_success": "Carpeta creada",
"create_folder_error": "Error al crear la carpeta", "create_folder_error": "Error al crear la carpeta",
"delete_success": "Eliminado correctamente", "delete_success": "Eliminado correctamente",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Archivos de {name}",
"create_file_success": "Archivo creado", "create_file_success": "Archivo creado",
"create_file_error": "Error al crear el archivo", "create_file_error": "Error al crear el archivo",
"favorites": "Favoritos", "favorites": "Favoritos",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "سرور این نام را نمی‌پذیرد.", "error_invalid_name": "سرور این نام را نمی‌پذیرد.",
"error_forbidden": "اجازهٔ انجام این کار را در اینجا ندارید.", "error_forbidden": "اجازهٔ انجام این کار را در اینجا ندارید.",
"error_too_large": "این فایل برای سرور بیش از حد بزرگ است.", "error_too_large": "این فایل برای سرور بیش از حد بزرگ است.",
"error_partial_copy": "بخشی از رونوشت در جای خود باقی ماند.",
"create_folder_success": "پوشه ایجاد شد", "create_folder_success": "پوشه ایجاد شد",
"create_folder_error": "ایجاد پوشه ناموفق بود", "create_folder_error": "ایجاد پوشه ناموفق بود",
"delete_success": "با موفقیت حذف شد", "delete_success": "با موفقیت حذف شد",
@@ -3435,6 +3436,7 @@
"shared": "اشتراکی", "shared": "اشتراکی",
"shared_with_me": "با من به اشتراک گذاشته شده", "shared_with_me": "با من به اشتراک گذاشته شده",
"shared_by": "به اشتراک گذاشته شده توسط {name}", "shared_by": "به اشتراک گذاشته شده توسط {name}",
"shared_files_from": "فایل‌های {name}",
"create_file_success": "فایل ایجاد شد", "create_file_success": "فایل ایجاد شد",
"create_file_error": "ایجاد فایل ناموفق بود", "create_file_error": "ایجاد فایل ناموفق بود",
"favorites": "مورد علاقه‌ها", "favorites": "مورد علاقه‌ها",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Le serveur n'accepte pas ce nom.", "error_invalid_name": "Le serveur n'accepte pas ce nom.",
"error_forbidden": "Vous n'avez pas l'autorisation de faire cela ici.", "error_forbidden": "Vous n'avez pas l'autorisation de faire cela ici.",
"error_too_large": "Ce fichier est trop volumineux pour le serveur.", "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_success": "Dossier créé",
"create_folder_error": "Échec de la création du dossier", "create_folder_error": "Échec de la création du dossier",
"delete_success": "Supprimé avec succès", "delete_success": "Supprimé avec succès",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Fichiers de {name}",
"create_file_success": "Fichier créé", "create_file_success": "Fichier créé",
"create_file_error": "Échec de la création du fichier", "create_file_error": "Échec de la création du fichier",
"favorites": "Favoris", "favorites": "Favoris",
+2
View File
@@ -3271,6 +3271,7 @@
"error_invalid_name": "השרת אינו מקבל את השם הזה.", "error_invalid_name": "השרת אינו מקבל את השם הזה.",
"error_forbidden": "אין לך הרשאה לבצע פעולה זו כאן.", "error_forbidden": "אין לך הרשאה לבצע פעולה זו כאן.",
"error_too_large": "הקובץ הזה גדול מדי עבור השרת.", "error_too_large": "הקובץ הזה גדול מדי עבור השרת.",
"error_partial_copy": "חלק מההעתק נשאר במקומו.",
"create_folder_success": "התיקיה נוצרה", "create_folder_success": "התיקיה נוצרה",
"create_folder_error": "יצירת התיקיה נכשלה", "create_folder_error": "יצירת התיקיה נכשלה",
"delete_success": "נמחק בהצלחה", "delete_success": "נמחק בהצלחה",
@@ -3354,6 +3355,7 @@
"shared": "משותף", "shared": "משותף",
"shared_with_me": "משותף איתי", "shared_with_me": "משותף איתי",
"shared_by": "משותף על ידי {name}", "shared_by": "משותף על ידי {name}",
"shared_files_from": "קבצים מאת {name}",
"open_folder_tree": "פתח עץ תיקייה", "open_folder_tree": "פתח עץ תיקייה",
"migration_title": "עדכון הקבצים שלך…", "migration_title": "עדכון הקבצים שלך…",
"migration_description": "ארגון תיקיות וקבצים לתוך המבנה הנכון שלהם. זה קורה רק פעם אחת." "migration_description": "ארגון תיקיות וקבצים לתוך המבנה הנכון שלהם. זה קורה רק פעם אחת."
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "A kiszolgáló nem fogadja el ezt a nevet.", "error_invalid_name": "A kiszolgáló nem fogadja el ezt a nevet.",
"error_forbidden": "Ehhez itt nincs jogosultságod.", "error_forbidden": "Ehhez itt nincs jogosultságod.",
"error_too_large": "Ez a fájl túl nagy a kiszolgáló számára.", "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_success": "Mappa létrehozva",
"create_folder_error": "Nem sikerült létrehozni a mappát", "create_folder_error": "Nem sikerült létrehozni a mappát",
"delete_success": "Sikeresen törölve", "delete_success": "Sikeresen törölve",
@@ -3435,6 +3436,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "{name} fájljai",
"create_file_success": "Fájl létrehozva", "create_file_success": "Fájl létrehozva",
"create_file_error": "Nem sikerült létrehozni a fájlt", "create_file_error": "Nem sikerült létrehozni a fájlt",
"favorites": "Kedvencek", "favorites": "Kedvencek",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Il server non accetta questo nome.", "error_invalid_name": "Il server non accetta questo nome.",
"error_forbidden": "Non hai l'autorizzazione per farlo qui.", "error_forbidden": "Non hai l'autorizzazione per farlo qui.",
"error_too_large": "Questo file è troppo grande per il server.", "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_success": "Cartella creata",
"create_folder_error": "Creazione della cartella non riuscita", "create_folder_error": "Creazione della cartella non riuscita",
"delete_success": "Eliminato con successo", "delete_success": "Eliminato con successo",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "File di {name}",
"create_file_success": "File creato", "create_file_success": "File creato",
"create_file_error": "Creazione del file non riuscita", "create_file_error": "Creazione del file non riuscita",
"favorites": "Preferiti", "favorites": "Preferiti",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "サーバーはこの名前を受け付けません。", "error_invalid_name": "サーバーはこの名前を受け付けません。",
"error_forbidden": "ここでこの操作を行う権限がありません。", "error_forbidden": "ここでこの操作を行う権限がありません。",
"error_too_large": "このファイルはサーバーには大きすぎます。", "error_too_large": "このファイルはサーバーには大きすぎます。",
"error_partial_copy": "コピーの一部は削除されずに残っています。",
"create_folder_success": "フォルダーを作成しました", "create_folder_success": "フォルダーを作成しました",
"create_folder_error": "フォルダーの作成に失敗しました", "create_folder_error": "フォルダーの作成に失敗しました",
"delete_success": "正常に削除しました", "delete_success": "正常に削除しました",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "{name} からのファイル",
"create_file_success": "ファイルを作成しました", "create_file_success": "ファイルを作成しました",
"create_file_error": "ファイルの作成に失敗しました", "create_file_error": "ファイルの作成に失敗しました",
"favorites": "お気に入り", "favorites": "お気に入り",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "서버에서 이 이름을 허용하지 않습니다.", "error_invalid_name": "서버에서 이 이름을 허용하지 않습니다.",
"error_forbidden": "여기에서 이 작업을 수행할 권한이 없습니다.", "error_forbidden": "여기에서 이 작업을 수행할 권한이 없습니다.",
"error_too_large": "이 파일은 서버에서 처리하기에 너무 큽니다.", "error_too_large": "이 파일은 서버에서 처리하기에 너무 큽니다.",
"error_partial_copy": "복사본의 일부가 그대로 남아 있어요.",
"create_folder_success": "폴더가 만들어졌어요", "create_folder_success": "폴더가 만들어졌어요",
"create_folder_error": "폴더를 만들지 못했어요", "create_folder_error": "폴더를 만들지 못했어요",
"delete_success": "성공적으로 삭제되었어요", "delete_success": "성공적으로 삭제되었어요",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "{name}님의 파일",
"create_file_success": "파일이 만들어졌어요", "create_file_success": "파일이 만들어졌어요",
"create_file_error": "파일을 만들지 못했어요", "create_file_error": "파일을 만들지 못했어요",
"favorites": "즐겨찾기", "favorites": "즐겨찾기",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Serveris nepieņem šo nosaukumu.", "error_invalid_name": "Serveris nepieņem šo nosaukumu.",
"error_forbidden": "Jums šeit nav atļaujas to darīt.", "error_forbidden": "Jums šeit nav atļaujas to darīt.",
"error_too_large": "Šis fails serverim ir pārāk liels.", "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_success": "Mape izveidota",
"create_folder_error": "Neizdevās izveidot mapi", "create_folder_error": "Neizdevās izveidot mapi",
"delete_success": "Sekmīgi izdzēsts", "delete_success": "Sekmīgi izdzēsts",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Faili no {name}",
"create_file_success": "Fails izveidots", "create_file_success": "Fails izveidots",
"create_file_error": "Neizdevās izveidot failu", "create_file_error": "Neizdevās izveidot failu",
"favorites": "Izlase", "favorites": "Izlase",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "Сервер энэ нэрийг хүлээн авахгүй байна.", "error_invalid_name": "Сервер энэ нэрийг хүлээн авахгүй байна.",
"error_forbidden": "Танд энд үүнийг хийх эрх байхгүй.", "error_forbidden": "Танд энд үүнийг хийх эрх байхгүй.",
"error_too_large": "Энэ файл серверийн хувьд хэт том байна.", "error_too_large": "Энэ файл серверийн хувьд хэт том байна.",
"error_partial_copy": "Хуулбарын нэг хэсэг хэвээр үлдлээ.",
"create_folder_success": "Хавтас үүсгэсэн", "create_folder_success": "Хавтас үүсгэсэн",
"create_folder_error": "Хавтас үүсгэж чадсангүй", "create_folder_error": "Хавтас үүсгэж чадсангүй",
"delete_success": "Амжилттай устгасан", "delete_success": "Амжилттай устгасан",
@@ -3435,6 +3436,7 @@
"shared": "Хуваалцсан", "shared": "Хуваалцсан",
"shared_with_me": "Надтай хуваалцсан", "shared_with_me": "Надтай хуваалцсан",
"shared_by": "{name}", "shared_by": "{name}",
"shared_files_from": "{name} хуваалцсан файлууд",
"create_file_success": "Файл үүсгэсэн", "create_file_success": "Файл үүсгэсэн",
"create_file_error": "Файлыг үүсгэж чадсангүй", "create_file_error": "Файлыг үүсгэж чадсангүй",
"favorites": "Дуртай зүйлс", "favorites": "Дуртай зүйлс",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "Serveren godtar ikke dette navnet.", "error_invalid_name": "Serveren godtar ikke dette navnet.",
"error_forbidden": "Du har ikke tillatelse til å gjøre dette her.", "error_forbidden": "Du har ikke tillatelse til å gjøre dette her.",
"error_too_large": "Denne filen er for stor for serveren.", "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_success": "Mappen er opprettet",
"create_folder_error": "Kunne ikke opprette mappe", "create_folder_error": "Kunne ikke opprette mappe",
"delete_success": "Elementet er slettet", "delete_success": "Elementet er slettet",
@@ -3435,6 +3436,7 @@
"shared": "Delt", "shared": "Delt",
"shared_with_me": "Delt med meg", "shared_with_me": "Delt med meg",
"shared_by": "Delt av {name}", "shared_by": "Delt av {name}",
"shared_files_from": "Filer fra {name}",
"create_file_success": "Filen er opprettet", "create_file_success": "Filen er opprettet",
"create_file_error": "Kunne ikke opprette filen", "create_file_error": "Kunne ikke opprette filen",
"favorites": "Favoritter", "favorites": "Favoritter",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "De server accepteert deze naam niet.", "error_invalid_name": "De server accepteert deze naam niet.",
"error_forbidden": "U hebt hier geen toestemming voor.", "error_forbidden": "U hebt hier geen toestemming voor.",
"error_too_large": "Dit bestand is te groot voor de server.", "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_success": "Map aangemaakt",
"create_folder_error": "Map aanmaken mislukt", "create_folder_error": "Map aanmaken mislukt",
"delete_success": "Succesvol verwijderd", "delete_success": "Succesvol verwijderd",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Bestanden van {name}",
"create_file_success": "Bestand aangemaakt", "create_file_success": "Bestand aangemaakt",
"create_file_error": "Bestand aanmaken mislukt", "create_file_error": "Bestand aanmaken mislukt",
"favorites": "Favorieten", "favorites": "Favorieten",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Serwer nie akceptuje tej nazwy.", "error_invalid_name": "Serwer nie akceptuje tej nazwy.",
"error_forbidden": "Nie masz tutaj uprawnień do wykonania tej czynności.", "error_forbidden": "Nie masz tutaj uprawnień do wykonania tej czynności.",
"error_too_large": "Ten plik jest zbyt duży dla serwera.", "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_success": "Folder utworzono",
"create_folder_error": "Nie udało się utworzyć folderu", "create_folder_error": "Nie udało się utworzyć folderu",
"delete_success": "Usunięto pomyślnie", "delete_success": "Usunięto pomyślnie",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Pliki od {name}",
"create_file_success": "Plik utworzono", "create_file_success": "Plik utworzono",
"create_file_error": "Nie udało się utworzyć pliku", "create_file_error": "Nie udało się utworzyć pliku",
"favorites": "Ulubione", "favorites": "Ulubione",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "O servidor não aceita este nome.", "error_invalid_name": "O servidor não aceita este nome.",
"error_forbidden": "Não tem permissão para fazer isso aqui.", "error_forbidden": "Não tem permissão para fazer isso aqui.",
"error_too_large": "Este arquivo é grande demais para o servidor.", "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_success": "Pasta criada",
"create_folder_error": "Falha ao criar a pasta", "create_folder_error": "Falha ao criar a pasta",
"delete_success": "Eliminado com sucesso", "delete_success": "Eliminado com sucesso",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Arquivos de {name}",
"create_file_success": "Arquivo criado", "create_file_success": "Arquivo criado",
"create_file_error": "Falha ao criar arquivo", "create_file_error": "Falha ao criar arquivo",
"favorites": "Favoritos", "favorites": "Favoritos",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "Serverul nu acceptă acest nume.", "error_invalid_name": "Serverul nu acceptă acest nume.",
"error_forbidden": "Nu aveți permisiunea de a face acest lucru aici.", "error_forbidden": "Nu aveți permisiunea de a face acest lucru aici.",
"error_too_large": "Acest fișier este prea mare pentru server.", "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_success": "Folder creat",
"create_folder_error": "Nu s-a putut crea folderul", "create_folder_error": "Nu s-a putut crea folderul",
"delete_success": "Șters cu succes", "delete_success": "Șters cu succes",
@@ -3435,6 +3436,7 @@
"shared": "Partajat", "shared": "Partajat",
"shared_with_me": "Partajat cu mine", "shared_with_me": "Partajat cu mine",
"shared_by": "Distribuit de {name}", "shared_by": "Distribuit de {name}",
"shared_files_from": "Fișiere de la {name}",
"create_file_success": "Fișier creat", "create_file_success": "Fișier creat",
"create_file_error": "Nu s-a putut crea fișierul", "create_file_error": "Nu s-a putut crea fișierul",
"favorites": "Favorite", "favorites": "Favorite",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Сервер не принимает это имя.", "error_invalid_name": "Сервер не принимает это имя.",
"error_forbidden": "У вас нет прав на это действие здесь.", "error_forbidden": "У вас нет прав на это действие здесь.",
"error_too_large": "Этот файл слишком большой для сервера.", "error_too_large": "Этот файл слишком большой для сервера.",
"error_partial_copy": "Часть копии осталась на месте.",
"create_folder_success": "Папка создана", "create_folder_success": "Папка создана",
"create_folder_error": "Не удалось создать папку", "create_folder_error": "Не удалось создать папку",
"delete_success": "Успешно удалено", "delete_success": "Успешно удалено",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Файлы от {name}",
"create_file_success": "Файл создан", "create_file_success": "Файл создан",
"create_file_error": "Не удалось создать файл", "create_file_error": "Не удалось создать файл",
"favorites": "Избранное", "favorites": "Избранное",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "Server tento názov neprijíma.", "error_invalid_name": "Server tento názov neprijíma.",
"error_forbidden": "Na túto akciu tu nemáte oprávnenie.", "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_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_success": "Priečinok bol vytvorený",
"create_folder_error": "Vytvorenie priečinka zlyhalo", "create_folder_error": "Vytvorenie priečinka zlyhalo",
"delete_success": "Úspešne odstránené", "delete_success": "Úspešne odstránené",
@@ -3435,6 +3436,7 @@
"shared": "Zdieľané", "shared": "Zdieľané",
"shared_with_me": "Zdieľané so mnou", "shared_with_me": "Zdieľané so mnou",
"shared_by": "Zdieľané od {name}", "shared_by": "Zdieľané od {name}",
"shared_files_from": "Súbory od {name}",
"create_file_success": "Súbor bol vytvorený", "create_file_success": "Súbor bol vytvorený",
"create_file_error": "Vytvorenie súboru zlyhalo", "create_file_error": "Vytvorenie súboru zlyhalo",
"favorites": "Obľúbené", "favorites": "Obľúbené",
+2
View File
@@ -3394,6 +3394,7 @@
"error_invalid_name": "Sunucu bu adı kabul etmiyor.", "error_invalid_name": "Sunucu bu adı kabul etmiyor.",
"error_forbidden": "Burada bunu yapma izniniz yok.", "error_forbidden": "Burada bunu yapma izniniz yok.",
"error_too_large": "Bu dosya sunucu için çok büyük.", "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_success": "Klasör oluşturuldu",
"create_folder_error": "Klasör oluşturulamadı", "create_folder_error": "Klasör oluşturulamadı",
"delete_success": "Başarıyla silindi", "delete_success": "Başarıyla silindi",
@@ -3433,6 +3434,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "{name} tarafından paylaşılan dosyalar",
"create_file_success": "Dosya oluşturuldu", "create_file_success": "Dosya oluşturuldu",
"create_file_error": "Dosya oluşturulamadı", "create_file_error": "Dosya oluşturulamadı",
"favorites": "Favoriler", "favorites": "Favoriler",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "Сервер не приймає цю назву.", "error_invalid_name": "Сервер не приймає цю назву.",
"error_forbidden": "У вас немає дозволу на цю дію тут.", "error_forbidden": "У вас немає дозволу на цю дію тут.",
"error_too_large": "Цей файл завеликий для сервера.", "error_too_large": "Цей файл завеликий для сервера.",
"error_partial_copy": "Частина копії залишилася на місці.",
"create_folder_success": "Папка створена", "create_folder_success": "Папка створена",
"create_folder_error": "Не вдалося створити папку", "create_folder_error": "Не вдалося створити папку",
"delete_success": "Успішно видалено", "delete_success": "Успішно видалено",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "Файли від {name}",
"create_file_success": "Файл створено", "create_file_success": "Файл створено",
"create_file_error": "Не вдалося створити файл", "create_file_error": "Не вдалося створити файл",
"favorites": "Вибране", "favorites": "Вибране",
+2
View File
@@ -3396,6 +3396,7 @@
"error_invalid_name": "伺服器不接受此名稱。", "error_invalid_name": "伺服器不接受此名稱。",
"error_forbidden": "你沒有權限在此處執行此操作。", "error_forbidden": "你沒有權限在此處執行此操作。",
"error_too_large": "此檔案對伺服器來說過大。", "error_too_large": "此檔案對伺服器來說過大。",
"error_partial_copy": "部分副本仍保留在原處。",
"create_folder_success": "資料夾已建立", "create_folder_success": "資料夾已建立",
"create_folder_error": "建立資料夾失敗", "create_folder_error": "建立資料夾失敗",
"delete_success": "刪除成功", "delete_success": "刪除成功",
@@ -3435,6 +3436,7 @@
"shared": "已分享", "shared": "已分享",
"shared_with_me": "與我共用", "shared_with_me": "與我共用",
"shared_by": "由 {name} 分享", "shared_by": "由 {name} 分享",
"shared_files_from": "來自 {name} 的檔案",
"create_file_success": "檔案已建立", "create_file_success": "檔案已建立",
"create_file_error": "建立檔案失敗", "create_file_error": "建立檔案失敗",
"favorites": "我的最愛", "favorites": "我的最愛",
+2
View File
@@ -3348,6 +3348,7 @@
"error_invalid_name": "服务器不接受此名称。", "error_invalid_name": "服务器不接受此名称。",
"error_forbidden": "你无权在此处执行此操作。", "error_forbidden": "你无权在此处执行此操作。",
"error_too_large": "此文件对服务器来说过大。", "error_too_large": "此文件对服务器来说过大。",
"error_partial_copy": "部分副本被保留在原处。",
"create_folder_success": "文件夹已创建", "create_folder_success": "文件夹已创建",
"create_folder_error": "创建文件夹失败", "create_folder_error": "创建文件夹失败",
"delete_success": "删除成功", "delete_success": "删除成功",
@@ -3387,6 +3388,7 @@
"shared": "Shared", "shared": "Shared",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"shared_by": "Shared by {name}", "shared_by": "Shared by {name}",
"shared_files_from": "来自 {name} 的文件",
"create_file_success": "文件已创建", "create_file_success": "文件已创建",
"create_file_error": "创建文件失败", "create_file_error": "创建文件失败",
"favorites": "收藏夹", "favorites": "收藏夹",
+11 -6
View File
@@ -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 or over JMAP as an administrator: \`x:Http/set\` with
\`{"update": {"singleton": {"usePermissiveCors": true}}}\`. It applies once \`{"update": {"singleton": {"usePermissiveCors": true}}}\`. It applies once
the settings are reloaded (the \`ReloadSettings\` action) or Stalwart the settings are reloaded (the \`ReloadSettings\` action) or Stalwart
restarts. Stalwart 0.16 and later ignore the \`[http] permissive-cors\` key restarts. On the current 1.0 pre-releases \`ReloadSettings\` fails while the
of the old TOML configuration. 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 A reverse-proxy rule works too if it allows your Lite origin with the
\`Authorization\` and \`Content-Type\` headers on \`/.well-known/jmap\`, \`/jmap/*\`, \`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 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 running bundle stays online. Older 0.16 releases and the 1.0 pre-releases
unmount the Application until a later \`UpdateApps\` succeeds. On every unmount the Application and drop the cached copy. The next restart,
version, a restart after the cached copy has expired, with \`resourceUrl\` \`UpdateApps\` or reload of the Applications (the other nodes of a cluster
unreachable, leaves the Application unmounted. So check that \`resourceUrl\` reload them after \`UpdateApps\`) downloads \`resourceUrl\` again and mounts the
downloads before running \`UpdateApps\` or restarting. 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 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 load); a freshly opened tab gets it immediately. Nothing stays stuck on old
+161 -6
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'; 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 { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types'; import type { FileNode } from '@/lib/jmap/types';
import { jmapError } from '@/lib/jmap/jmap-error'; import { jmapError } from '@/lib/jmap/jmap-error';
@@ -420,9 +420,9 @@ describe('file-store "Shared with me"', () => {
const T = '2026-09-01T08:00:00Z'; const T = '2026-09-01T08:00:00Z';
// Another user's nodes as listAllFileNodesAcrossAccounts hands them over. // Another user's nodes as listAllFileNodesAcrossAccounts hands them over.
const shared = (id: string, name: string, parentId: string | null, blobId: string | null = null): FileNode => ({ 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, 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', 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. // Stalwart 1.0's ancestors of a shared node: no dates, no rights.
const placeholder = (id: string, name: string, parentId: string | null): FileNode => ({ 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'); await useFileStore.getState().navigate('owner:f', 'Shared');
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['old.txt']); 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<unknown>) => 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)', () => { describe('file-store symlinks (Stalwart 1.0)', () => {
@@ -522,12 +665,24 @@ describe('file-store symlinks (Stalwart 1.0)', () => {
useFileStore.getState().copyResources(['a.txt', 'latest']); useFileStore.getState().copyResources(['a.txt', 'latest']);
expect(useFileStore.getState().clipboard).toMatchObject({ mode: 'copy', ids: ['f'], names: ['a.txt'] }); 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'); await useFileStore.getState().duplicateResource('latest');
expect(client._nodes()).toHaveLength(2); 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', () => { describe('file-store upload batches', () => {
+147 -19
View File
@@ -1,8 +1,9 @@
import { create } from 'zustand'; 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 type { FileNode, FileNodeRights } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind'; import { fileNodeKind } from '@/lib/jmap/filenode-kind';
import { fileNodeDepth } from '@/lib/jmap/filenode-depth'; import { fileNodeDepth } from '@/lib/jmap/filenode-depth';
import { jmapError } from '@/lib/jmap/jmap-error';
import { folderDepthError } from '@/lib/file-errors'; import { folderDepthError } from '@/lib/file-errors';
export interface FileResource { export interface FileResource {
@@ -132,6 +133,13 @@ interface FileState {
shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>; shareResource: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
/** Load the top-level nodes shared with the user by other principals. */ /** Load the top-level nodes shared with the user by other principals. */
loadSharedRoots: () => Promise<void>; loadSharedRoots: () => Promise<void>;
/**
* Open a node of a drive shared with the user, by the id the listings give
* it ("<owner>:<id>"), 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<boolean>;
} }
const DIRECTORY_TYPES = new Set(['d', 'application/x-directory', 'text/directory', 'httpd/unix-directory', 'inode/directory']); 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(':'); 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,
// "<owner>:*": 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<string>();
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 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 * 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>): string {
return `${base} (${counter})${ext}`; 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<string>();
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 { function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/'; if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/'); return '/' + stack.slice(1).map(s => s.name).join('/');
@@ -518,6 +617,9 @@ export const useFileStore = create<FileState>((set, get) => ({
let newStack: { id: string | null; name: string }[]; let newStack: { id: string | null; name: string }[];
if (parentId === null) { if (parentId === null) {
newStack = [{ id: null, name: '' }]; 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 { } else {
// Check if navigating to a parent in the stack // Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId); const existingIdx = pathStack.findIndex(s => s.id === parentId);
@@ -543,11 +645,14 @@ export const useFileStore = create<FileState>((set, get) => ({
const allNodes = isCrossAccountId(parentId) const allNodes = isCrossAccountId(parentId)
? await client.listAllFileNodesAcrossAccounts() ? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes(); : await client.listAllFileNodes();
// The owner's folders on the way down to a shared node aren't shown const owner = sharedFilesOwner(parentId);
// (see loadSharedRoots); the user can do nothing in them. const resources = owner !== null
const resources = sortResources( ? withUniqueNames(sortResources(
childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource), 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 // Prune recent files whose backing node no longer exists on the server
const { recentFiles } = get(); const { recentFiles } = get();
@@ -617,6 +722,7 @@ export const useFileStore = create<FileState>((set, get) => ({
createDirectory: async (name: string) => { createDirectory: async (name: string) => {
const { client, currentParentId, refresh } = get(); const { client, currentParentId, refresh } = get();
if (!client) return; if (!client) return;
assertCanAddTo(currentParentId);
await client.createFileDirectory(name, currentParentId); await client.createFileDirectory(name, currentParentId);
await refresh(); await refresh();
@@ -625,6 +731,7 @@ export const useFileStore = create<FileState>((set, get) => ({
uploadFile: async (file: File) => { uploadFile: async (file: File) => {
const { client, currentParentId } = get(); const { client, currentParentId } = get();
if (!client) return; if (!client) return;
assertCanAddTo(currentParentId);
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
@@ -650,6 +757,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { client, currentParentId, resources } = get(); const { client, currentParentId, resources } = get();
const report: UploadReport = { uploaded: 0, failed: [] }; const report: UploadReport = { uploaded: 0, failed: [] };
if (!client) return report; if (!client) return report;
assertCanAddTo(currentParentId);
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
@@ -697,6 +805,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { client, currentParentId } = get(); const { client, currentParentId } = get();
const report: UploadReport = { uploaded: 0, failed: [] }; const report: UploadReport = { uploaded: 0, failed: [] };
if (!client || files.length === 0) return report; if (!client || files.length === 0) return report;
assertCanAddTo(currentParentId);
const abortController = new AbortController(); const abortController = new AbortController();
set({ uploadAbortController: abortController }); set({ uploadAbortController: abortController });
@@ -897,6 +1006,7 @@ export const useFileStore = create<FileState>((set, get) => ({
createTextFile: async (name: string) => { createTextFile: async (name: string) => {
const { client, currentParentId, refresh } = get(); const { client, currentParentId, refresh } = get();
if (!client) return; if (!client) return;
assertCanAddTo(currentParentId);
const emptyBlob = new File([''], name, { type: 'text/plain' }); const emptyBlob = new File([''], name, { type: 'text/plain' });
const { blobId } = await client.uploadBlob(emptyBlob); const { blobId } = await client.uploadBlob(emptyBlob);
@@ -910,6 +1020,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const resource = resources.find(r => r.name === name); const resource = resources.find(r => r.name === name);
if (!resource || resource.isSymlink) return; if (!resource || resource.isSymlink) return;
assertCanAddTo(currentParentId);
const dotIdx = name.lastIndexOf('.'); const dotIdx = name.lastIndexOf('.');
const copyName = dotIdx > 0 const copyName = dotIdx > 0
@@ -976,7 +1087,12 @@ export const useFileStore = create<FileState>((set, get) => ({
const picked = names const picked = names
.map(n => resources.find(r => r.name === n)) .map(n => resources.find(r => r.name === n))
.filter((r): r is FileResource => !!r && !r.isSymlink); .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({ set({
clipboard: { clipboard: {
mode: 'copy', mode: 'copy',
@@ -992,8 +1108,11 @@ export const useFileStore = create<FileState>((set, get) => ({
pasteResources: async () => { pasteResources: async () => {
const { client, currentParentId, clipboard, refresh } = get(); const { client, currentParentId, clipboard, refresh } = get();
if (!client || !clipboard) return; if (!client || !clipboard) return;
assertCanAddTo(currentParentId);
const entries: UndoAction['entries'] = []; 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++) { for (let i = 0; i < clipboard.ids.length; i++) {
const id = clipboard.ids[i]; const id = clipboard.ids[i];
@@ -1003,7 +1122,7 @@ export const useFileStore = create<FileState>((set, get) => ({
await client.updateFileNode(id, { parentId: currentParentId }); await client.updateFileNode(id, { parentId: currentParentId });
entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } }); entries.push({ id, from: { parentId: clipboard.sourceParentId }, to: { parentId: currentParentId } });
} else { } else {
await client.copyFileNode(id, displayName, currentParentId); await client.copyFileNode(id, displayName, currentParentId, listings);
} }
} }
@@ -1117,21 +1236,30 @@ export const useFileStore = create<FileState>((set, get) => ({
return; return;
} }
try { try {
// Stalwart 1.0 also lists the owner's folders above a shared node const roots = sharedRootsOf(await client.listAllFileNodesAcrossAccounts());
// (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)),
);
set({ sharedRoots: sortResources(roots.map(nodeToResource)) }); set({ sharedRoots: sortResources(roots.map(nodeToResource)) });
} catch (error) { } catch (error) {
console.error('[Files] Failed to load shared roots:', error); console.error('[Files] Failed to load shared roots:', error);
set({ sharedRoots: [] }); 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;
},
})); }));