diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx index 0b0b54f60..854ef7865 100644 --- a/app/(main)/admin/_tabs/settings.tsx +++ b/app/(main)/admin/_tabs/settings.tsx @@ -153,6 +153,16 @@ export function SettingsTab() { )} + + + +
+

+ Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host. +

+
+
+ diff --git a/app/api/wopi/files/[fileId]/contents/route.ts b/app/api/wopi/files/[fileId]/contents/route.ts new file mode 100644 index 000000000..af9a29b77 --- /dev/null +++ b/app/api/wopi/files/[fileId]/contents/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { downloadFileBlob, getWopiFileNode, updateFileNodeBlob, uploadFileBlob } from '@/lib/wopi/files'; +import { wopiContext } from '@/lib/wopi/request'; + +/** + * WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile + * streams the node's blob, PutFile uploads the editor's bytes as a new blob + * and points the FileNode at it via `FileNode/set { blobId }`. + */ + +/** GET = GetFile */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ fileId: string }> }, +) { + try { + const { fileId } = await params; + const auth = await wopiContext(request, fileId); + if (!auth) { + return NextResponse.json({ error: 'Invalid access token' }, { status: 401 }); + } + + const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId); + if (!node || !node.blobId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + const upstream = await downloadFileBlob( + auth.ctx, auth.payload.accountId, node.blobId, node.name, node.type, + ); + if (!upstream.ok) { + return NextResponse.json({ error: 'Blob download failed' }, { status: 502 }); + } + + const headers = new Headers(); + headers.set('Content-Type', node.type || 'application/octet-stream'); + const contentLength = upstream.headers.get('Content-Length'); + if (contentLength) headers.set('Content-Length', contentLength); + return new NextResponse(upstream.body, { status: 200, headers }); + } catch (error) { + logger.error('WOPI GetFile failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** POST = PutFile (X-WOPI-Override: PUT) */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ fileId: string }> }, +) { + try { + const { fileId } = await params; + const auth = await wopiContext(request, fileId); + if (!auth) { + return NextResponse.json({ error: 'Invalid access token' }, { status: 401 }); + } + if (!auth.payload.canWrite) { + return NextResponse.json({ error: 'Read-only token' }, { status: 403 }); + } + + const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId); + if (!node || !node.blobId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + // Collabora sends the LastModifiedTime it saw at load/last save. A + // mismatch means someone else changed the document meanwhile - answer + // 409 + COOLStatusCode 1010 so the editor prompts the user instead of + // silently overwriting. + const clientStamp = + request.headers.get('X-COOL-WOPI-Timestamp') || request.headers.get('X-LOOL-WOPI-Timestamp'); + if (clientStamp && node.modified) { + const ours = Date.parse(node.modified); + const theirs = Date.parse(clientStamp); + if (Number.isFinite(ours) && Number.isFinite(theirs) && ours !== theirs) { + return NextResponse.json({ COOLStatusCode: 1010 }, { status: 409 }); + } + } + + const body = await request.arrayBuffer(); + if (body.byteLength === 0) { + return NextResponse.json({ error: 'Empty document rejected' }, { status: 409 }); + } + + const blob = await uploadFileBlob(auth.ctx, auth.payload.accountId, body, node.type); + const { modified } = await updateFileNodeBlob( + auth.ctx, auth.payload.accountId, fileId, blob.blobId, + ); + + return NextResponse.json({ LastModifiedTime: modified }); + } catch (error) { + logger.error('WOPI PutFile failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/wopi/files/[fileId]/route.ts b/app/api/wopi/files/[fileId]/route.ts new file mode 100644 index 000000000..d13c0802e --- /dev/null +++ b/app/api/wopi/files/[fileId]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { decodeFileNodeName } from '@/lib/jmap/filenode-name'; +import { getWopiFileNode } from '@/lib/wopi/files'; +import { wopiContext } from '@/lib/wopi/request'; + +/** + * WOPI CheckFileInfo + file-level operations (#425). + * + * Called by the WOPI client (Collabora/OnlyOffice/EuroOffice) server-to-server + * with the access token minted by /api/wopi/launch - there is no session + * cookie on these requests. + */ + +/** GET = CheckFileInfo */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ fileId: string }> }, +) { + try { + const { fileId } = await params; + const auth = await wopiContext(request, fileId); + if (!auth) { + return NextResponse.json({ error: 'Invalid access token' }, { status: 401 }); + } + + const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId); + if (!node || !node.blobId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + return NextResponse.json({ + BaseFileName: decodeFileNodeName(node.name), + Size: node.size, + OwnerId: auth.payload.username, + UserId: auth.payload.username, + UserFriendlyName: auth.payload.username, + UserCanWrite: auth.payload.canWrite, + UserCanNotWriteRelative: true, + SupportsUpdate: true, + SupportsLocks: false, + LastModifiedTime: node.modified, + Version: node.blobId, + PostMessageOrigin: auth.payload.origin, + }); + } catch (error) { + logger.error('WOPI CheckFileInfo failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +const LOCK_OPERATIONS = new Set(['LOCK', 'UNLOCK', 'REFRESH_LOCK', 'GET_LOCK']); + +/** + * POST = lock operations. FileNodes have no lock concept to map these onto, + * so they are acknowledged as no-ops (CheckFileInfo advertises + * SupportsLocks: false; well-behaved clients don't send them at all). + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ fileId: string }> }, +) { + const { fileId } = await params; + const auth = await wopiContext(request, fileId); + if (!auth) { + return NextResponse.json({ error: 'Invalid access token' }, { status: 401 }); + } + + const operation = request.headers.get('X-WOPI-Override')?.toUpperCase() || ''; + if (LOCK_OPERATIONS.has(operation)) { + const headers = new Headers(); + if (operation === 'GET_LOCK') headers.set('X-WOPI-Lock', ''); + return new NextResponse(null, { status: 200, headers }); + } + return NextResponse.json({ error: 'Unsupported operation' }, { status: 501 }); +} diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts new file mode 100644 index 000000000..f61ffeb9f --- /dev/null +++ b/app/api/wopi/launch/route.ts @@ -0,0 +1,93 @@ +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 { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery'; +import { getWopiFileNode } from '@/lib/wopi/files'; +import { mintWopiToken } from '@/lib/wopi/token'; + +/** + * POST /api/wopi/launch { fileId: string, accountId?: string } + * + * Mints a WOPI access token scoped to one file node and returns the editor + * URL to POST it to (#425). Session-authenticated - this is the only WOPI + * route the browser calls; everything under /api/wopi/files is called by the + * editor server-to-server with the token minted here. + */ +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { fileId?: unknown; accountId?: unknown } | null; + const fileId = typeof body?.fileId === 'string' ? body.fileId : ''; + if (!fileId) { + return NextResponse.json({ error: 'fileId is required' }, { status: 400 }); + } + + const actions = await getWopiActions(); + if (!actions) { + return NextResponse.json({ error: 'No document editor is configured' }, { status: 503 }); + } + + const ctx = { serverUrl: creds.serverUrl, authHeader: creds.authHeader, trusted: creds.trusted }; + + let accountId = typeof body?.accountId === 'string' ? body.accountId : ''; + if (!accountId) { + const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted }); + accountId = + session?.primaryAccounts?.['urn:ietf:params:jmap:filenode'] || + Object.keys(session?.accounts ?? {})[0] || + ''; + } + if (!accountId) { + return NextResponse.json({ error: 'No files account' }, { status: 404 }); + } + + const node = await getWopiFileNode(ctx, accountId, fileId); + if (!node || !node.blobId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + const ext = node.name.split('.').pop()?.toLowerCase() || ''; + const canWrite = node.myRights ? !!node.myRights.mayModifyContent : true; + const urlsrc = (canWrite && actions.edit[ext]) || actions.view[ext] || actions.edit[ext]; + if (!urlsrc) { + return NextResponse.json({ error: 'File type not supported by the editor' }, { status: 415 }); + } + const editable = canWrite && !!actions.edit[ext]; + + // Where the editor reaches this webmail. Deployments where the editor + // sees a different host than the browser (docker networks, split DNS) + // override it via wopiHostUrl. + const hostBase = + configManager.get('wopiHostUrl', '').trim().replace(/\/+$/, '') || + request.nextUrl.origin; + const wopiSrc = `${hostBase}/api/wopi/files/${encodeURIComponent(fileId)}`; + + const { token, expiresAt } = mintWopiToken({ + serverUrl: creds.serverUrl, + authHeader: creds.authHeader, + username: creds.username, + accountId, + fileId, + canWrite: editable, + origin: request.nextUrl.origin, + }); + + return NextResponse.json({ + url: buildWopiActionUrl(urlsrc, wopiSrc), + accessToken: token, + accessTokenTtl: expiresAt, + readOnly: !editable, + }); + } catch (error) { + logger.error('WOPI launch failed', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/wopi/status/route.ts b/app/api/wopi/status/route.ts new file mode 100644 index 000000000..07fd538bc --- /dev/null +++ b/app/api/wopi/status/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { getWopiActions, getWopiClientUrl } from '@/lib/wopi/discovery'; + +/** + * GET /api/wopi/status + * + * Tells the Files UI whether a WOPI document editor is configured and which + * file extensions it can edit/view (#425). Session-authenticated; the WOPI + * client URL itself stays server-side. + */ +export async function GET(request: NextRequest) { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const clientUrl = await getWopiClientUrl(); + if (!clientUrl) { + return NextResponse.json({ enabled: false, editExtensions: [], viewExtensions: [] }); + } + + const actions = await getWopiActions(); + if (!actions) { + return NextResponse.json({ enabled: false, editExtensions: [], viewExtensions: [] }); + } + + return NextResponse.json({ + enabled: true, + editExtensions: Object.keys(actions.edit), + viewExtensions: Object.keys(actions.view), + }); +} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 131216b03..ff2bc5a90 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -11,7 +11,7 @@ import { AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, - Menu, Users, Share2, + Menu, Users, Share2, SquarePen, } from "lucide-react"; import { useIsDesktop } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -80,6 +80,9 @@ interface FileBrowserProps { onMoveToParent: (names: string[]) => Promise; onPreviewImage: (name: string) => void; onPreviewFile: (name: string) => void; + /** WOPI document editing (#425): whether a file can open in the document editor. */ + isOfficeEditable?: (name: string) => boolean; + onEditFile?: (name: string) => void; onShowDetails: (name: string) => void; onCreateTextFile: (name: string) => Promise; onDuplicate: (name: string) => Promise; @@ -365,6 +368,8 @@ export function FileBrowser({ onMoveToParent, onPreviewImage, onPreviewFile, + isOfficeEditable, + onEditFile, onShowDetails, onCreateTextFile, onDuplicate, @@ -686,6 +691,8 @@ export function FileBrowser({ ? `/${resource.name}` : `${currentPath}/${resource.name}`; onNavigate(newPath, resource.id); + } else if (isOfficeEditable?.(resource.name) && onEditFile) { + onEditFile(resource.name); } else if (isPreviewable(resource.name)) { if (isImageFile(resource.name)) { onPreviewImage(resource.name); @@ -1697,6 +1704,18 @@ export function FileBrowser({ {t("preview")} )} + {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeEditable?.(contextMenu.name) && onEditFile && ( + + )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( + + +
+ {error ? ( +
+ {error} +
+ ) : ( + <> + {!frameLoaded && ( +
+ + {t("office_loading")} +
+ )} +