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