/** * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import Busboy from 'busboy'; import type { Request, Response } from 'express'; import { posix as pathPosix } from 'node:path'; import { pipeline } from 'node:stream/promises'; import type { Actor } from '../../core/actor.js'; import { effectiveActorApp } from '../../core/actor.js'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; import { Controller, Get, Post } from '../../core/http/decorators.js'; import { assertNormalized } from '../../services/fs/resolveNode.js'; import type { PreparedBatchWrite, UploadedBatchWriteItem, UploadProgressTrackerLike, } from '../../services/fs/types.js'; import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js'; import { runWithConcurrencyLimit, runWithConcurrencyLimitSettled, } from '../../util/concurrency.js'; import { PuterController } from '../types.js'; import { FS_COSTS } from './costs.js'; import type { CompleteWriteRequest, CompleteWriteResponse, SignedWriteRequest, SignedWriteResponse, SignMultipartPartsRequest, SignMultipartPartsResponse, WriteGuiMetadata, WriteRequest, WriteResponse, } from './requestTypes.js'; import type { AbortWriteRequest, BatchWriteManifest, BatchWriteManifestItem, ParsedMultipartBatchManifest, RouteParams, ThumbnailUploadPrepareItem, ThumbnailUploadPreparePayload, } from './types.js'; import { toLegacyEntry } from './legacyFsHelpers.js'; class UploadProgressTracker implements UploadProgressTrackerLike { total = 0; progress = 0; #listeners: Array<(delta: number) => void> = []; setTotal(value: number) { this.total = value; } add(amount: number) { this.progress += amount; for (const listener of this.#listeners) { listener(amount); } } subscribe(callback: (delta: number) => void) { this.#listeners.push(callback); return { detach: () => { const idx = this.#listeners.indexOf(callback); if (idx !== -1) this.#listeners.splice(idx, 1); }, }; } } const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024; const DEFAULT_BATCH_ACL_CHECK_CONCURRENCY = 32; const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8; @Controller('/fs') export class FSController extends PuterController { override getReportedCosts() { return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({ usageType, ucentsPerUnit, unit: 'byte', source: 'controller:fs', })); } @Post('/startWrite', { subdomain: 'api', requireVerified: true }) async startWrite( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); requestBody.fileMetadata = this.#normalizeFileMetadataPath( req, requestBody.fileMetadata, requestBody, ); requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( requestBody.fileMetadata, requestBody, ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); const { response, createdDirectoryEntries } = await this.services.fs.startUrlWriteWithCreatedDirectories( userId, requestBody, storageAllowanceMax, ); await this.#attachSignedThumbnailUploadTargets( [requestBody], [response], ); if (!requestBody.directory) { await this.#runNonCritical(async () => { await this.#emitGuiPendingWriteEvent( userId, requestBody, response, ); }, 'emitStartWritePendingEvent'); } if (createdDirectoryEntries.length > 0) { void this.#runNonCritical(async () => { for (const createdDirectoryEntry of createdDirectoryEntries) { await this.#emitGuiWriteEvent( 'outer.gui.item.added', createdDirectoryEntry, requestBody.guiMetadata, ); } }, 'emitStartWriteDirectoryEvents'); } res.json(response); } @Post('/startBatchWrite', { subdomain: 'api', requireVerified: true }) async startBatchWrites( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const appUidLookupCache = new Map>(); const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { const normalizedRequestBody = this.#withGuiMetadata( requestBody, req.body, ); normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, ); normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( normalizedRequestBody.fileMetadata, normalizedRequestBody, appUidLookupCache, ); return normalizedRequestBody; }), ) : []; await this.#assertBatchWriteAccess( req, requests.map((requestBody) => requestBody.fileMetadata), { pathAlreadyNormalized: true }, ); const { responses, createdDirectoryEntries } = await this.services.fs.batchStartUrlWritesWithCreatedDirectories( userId, requests, storageAllowanceMax, ); const directoryGuiMetadataByPath = new Map< string, WriteGuiMetadata | undefined >( requests .filter((request) => request.directory) .map((request) => [ request.fileMetadata.path, request.guiMetadata, ]), ); const emittedDirectoryPaths = new Set(); await this.#attachSignedThumbnailUploadTargets(requests, responses); await this.#runNonCritical(async () => { await runWithConcurrencyLimit( responses, 32, async (writeResponse, index) => { const requestBody = requests[index]; if (requestBody && writeResponse) { if (!requestBody.directory) { await this.#emitGuiPendingWriteEvent( userId, requestBody, writeResponse, ); } } }, ); }, 'emitStartBatchWritePendingEvents'); if (createdDirectoryEntries.length > 0) { void this.#runNonCritical(async () => { for (const createdDirectoryEntry of createdDirectoryEntries) { if (emittedDirectoryPaths.has(createdDirectoryEntry.path)) { continue; } emittedDirectoryPaths.add(createdDirectoryEntry.path); await this.#emitGuiWriteEvent( 'outer.gui.item.added', createdDirectoryEntry, directoryGuiMetadataByPath.get( createdDirectoryEntry.path, ), ); } }, 'emitStartBatchWriteDirectoryEvents'); } res.json(responses); } @Post('/completeWrite', { subdomain: 'api', requireVerified: true }) async completeWrite( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const requestBody = this.#withGuiMetadata(req.body, req.body); this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); const response = await this.services.fs.completeUrlWrite( userId, requestBody, ); const writeResponse = await this.#applyWriteResponseSideEffects( userId, { fsEntry: response.fsEntry, wasOverwrite: response.wasOverwrite, requestedThumbnail: response.requestedThumbnail, contentHashSha256: null, }, requestBody.guiMetadata, ); res.json({ ...response, fsEntry: writeResponse.fsEntry }); } @Post('/completeBatchWrite', { subdomain: 'api', requireVerified: true }) async completeBatchWrites( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const requests = Array.isArray(req.body) ? req.body.map((requestBody) => { return this.#withGuiMetadata(requestBody, req.body); }) : []; for (const requestBody of requests) { this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); } const response = await this.services.fs.batchCompleteUrlWrite( userId, requests, ); const updatedResponse = await runWithConcurrencyLimit( response, DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY, async (writeResponse, index) => { const requestBody = requests[index]; const withSideEffects = await this.#applyWriteResponseSideEffects( userId, { fsEntry: writeResponse.fsEntry, wasOverwrite: writeResponse.wasOverwrite, requestedThumbnail: writeResponse.requestedThumbnail, contentHashSha256: null, }, requestBody?.guiMetadata, ); return { ...writeResponse, fsEntry: withSideEffects.fsEntry }; }, ); res.json(updatedResponse); } @Post('/abortWrite', { subdomain: 'api', requireVerified: true }) async abortWrite( req: Request, res: Response<{ ok: true }>, ) { const userId = this.#getActorUserId(req); if (!req.body?.uploadId) { throw new HttpError(400, 'Missing uploadId', { legacyCode: 'bad_request', }); } await this.services.fs.abortUrlWrite(userId, req.body.uploadId); res.json({ ok: true }); } @Post('/signMultipartParts', { subdomain: 'api', requireVerified: true }) async signMultipartParts( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const response = await this.services.fs.signMultipartParts( userId, req.body, ); res.json(response); } @Post('/write', { subdomain: 'api', requireVerified: true }) async write( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); requestBody.fileMetadata = this.#normalizeFileMetadataPath( req, requestBody.fileMetadata, requestBody, ); requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( requestBody.fileMetadata, requestBody, ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); const normalizedPath = this.#normalizePath( requestBody.fileMetadata.path, ); const uploadTracker = await this.#createUploadTracker( userId, normalizedPath, normalizedPath, Number(requestBody.fileMetadata.size ?? 0), requestBody.guiMetadata, ); const response = await this.services.fs.write( userId, requestBody, uploadTracker, storageAllowanceMax, ); const updatedResponse = await this.#applyWriteResponseSideEffects( userId, response, requestBody.guiMetadata, ); res.json(updatedResponse); } @Post('/batchWrite', { subdomain: 'api', requireVerified: true }) async batchWrites( req: Request, res: Response, ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestMode = this.#resolveBatchWriteRequestMode(req); const appUidLookupCache = new Map>(); if (requestMode === 'multipart') { let parsedManifest: ParsedMultipartBatchManifest | null = null; let preparedBatch: PreparedBatchWrite | null = null; let manifestPreparationPromise: Promise | null = null; let parseFailure: Error | null = null; const uploadPromises: Promise[] = []; const uploadedIndexes = new Set(); let fileOrderIndex = 0; const failParse = (error: unknown) => { if (parseFailure) { return; } if (error instanceof Error) { parseFailure = error; return; } parseFailure = new Error(String(error)); }; const busboy = Busboy({ headers: req.headers }); busboy.on('field', (fieldName, value, info) => { if ( (info as unknown as { filenameTruncated: string }) .filenameTruncated || info.nameTruncated || info.valueTruncated ) { failParse( new HttpError( 400, 'Batch write manifest field is truncated', { legacyCode: 'bad_request' }, ), ); return; } if (fieldName !== 'manifest') { return; } if (manifestPreparationPromise) { failParse( new HttpError( 409, 'Batch write manifest was provided more than once', { legacyCode: 'conflict' }, ), ); return; } try { parsedManifest = this.#parseBatchWriteManifest( value, undefined, ); const ignoredItemIndexes = new Set(); parsedManifest = { ...parsedManifest, items: parsedManifest.items.map((item) => ({ ...item, fileMetadata: this.#normalizeFileMetadataPath( req, item.fileMetadata, item, ), })), ignoredItemIndexes, }; for (const item of parsedManifest.items) { if ( this.#shouldIgnoreUploadPath(item.fileMetadata.path) ) { ignoredItemIndexes.add(item.index); } } manifestPreparationPromise = (async () => { try { if (!parsedManifest) { throw new HttpError( 400, 'Batch write manifest is missing', { legacyCode: 'bad_request' }, ); } parsedManifest = { ...parsedManifest, items: await Promise.all( parsedManifest.items.map(async (item) => ({ ...item, fileMetadata: await this.#resolveAssociatedAppMetadata( item.fileMetadata, item, appUidLookupCache, ), })), ), }; const activeManifestItems = parsedManifest.items.filter( (item) => !parsedManifest?.ignoredItemIndexes?.has( item.index, ), ); await this.#assertBatchWriteAccess( req, activeManifestItems.map( (item) => item.fileMetadata, ), { pathAlreadyNormalized: true }, ); preparedBatch = await this.services.fs.prepareBatchWrites( userId, activeManifestItems.map((item) => ({ fileMetadata: item.fileMetadata, thumbnailData: item.thumbnailData, guiMetadata: item.guiMetadata, })), storageAllowanceMax, ); await this.services.fs.assertStorageAllowanceForPreparedBatch( preparedBatch, undefined, storageAllowanceMax, ); } catch (error) { failParse(error); } })(); } catch (error) { failParse(error); } }); busboy.on('file', (fieldName, stream) => { const currentFileOrder = fileOrderIndex; fileOrderIndex++; const uploadPromise = (async () => { try { if (parseFailure) { throw parseFailure; } if (!manifestPreparationPromise) { throw new HttpError( 400, 'Batch write manifest must come before file content', { legacyCode: 'bad_request' }, ); } await manifestPreparationPromise; if (parseFailure) { throw parseFailure; } if (!parsedManifest || !preparedBatch) { throw new HttpError( 400, 'Batch write manifest is missing', { legacyCode: 'bad_request' }, ); } const itemIndex = this.#resolveMultipartFileIndex( fieldName, currentFileOrder, parsedManifest, ); if (parsedManifest.ignoredItemIndexes.has(itemIndex)) { if (!stream.readableEnded && !stream.destroyed) { stream.resume(); } return null; } if (uploadedIndexes.has(itemIndex)) { throw new HttpError( 409, `Duplicate file content for batch index ${itemIndex}`, { legacyCode: 'conflict' }, ); } uploadedIndexes.add(itemIndex); const preparedItem = preparedBatch.itemsByIndex.get(itemIndex); if (!preparedItem) { throw new HttpError( 400, `Batch write metadata was not found for index ${itemIndex}`, { legacyCode: 'bad_request' }, ); } const uploadTracker = await this.#createUploadTracker( userId, preparedItem.objectKey, preparedItem.normalizedInput.path, preparedItem.normalizedInput.size, preparedItem.guiMetadata, ); return await this.services.fs.uploadPreparedBatchItem({ preparedBatch, itemIndex, fileContent: stream, uploadTracker, }); } catch (error) { if (!stream.readableEnded && !stream.destroyed) { stream.resume(); } throw error; } })(); uploadPromises.push(uploadPromise); }); const parsingComplete = new Promise((resolve, reject) => { busboy.once('error', reject); busboy.once('close', resolve); }); req.pipe(busboy); await parsingComplete; if (!manifestPreparationPromise) { await Promise.allSettled(uploadPromises); throw new HttpError(400, 'Batch write manifest is required', { legacyCode: 'bad_request', }); } await manifestPreparationPromise; const uploadResults = await Promise.allSettled(uploadPromises); const uploadedItems = uploadResults .filter( ( result, ): result is PromiseFulfilledResult => result.status === 'fulfilled', ) .map((result) => result.value) .filter( (uploadedItem): uploadedItem is UploadedBatchWriteItem => uploadedItem !== null, ); if (parseFailure) { if (preparedBatch) { await this.services.fs.cleanupPreparedBatchUploads( preparedBatch, uploadedItems, ); } throw parseFailure; } if (!preparedBatch) { throw new HttpError( 500, 'Failed to prepare batch write operation', { legacyCode: 'internal_error' }, ); } const failedUpload = uploadResults.find( (result) => result.status === 'rejected', ); if (failedUpload?.status === 'rejected') { await this.services.fs.cleanupPreparedBatchUploads( preparedBatch, uploadedItems, ); throw failedUpload.reason instanceof Error ? failedUpload.reason : new Error('Failed to upload multipart batch item'); } const writeResponses = await this.services.fs.finalizePreparedBatchWrites( preparedBatch, uploadedItems, ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, async (writeResponse, index) => { const preparedItem = preparedBatch?.items[index]; return this.#applyWriteResponseSideEffects( userId, writeResponse, preparedItem?.guiMetadata, ); }, ); res.json(updatedResponses); return; } const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { const normalizedRequestBody = this.#withGuiMetadata( requestBody, req.body, ); normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, ); normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( normalizedRequestBody.fileMetadata, normalizedRequestBody, appUidLookupCache, ); return normalizedRequestBody; }), ) : []; const filteredRequests = requests.filter((requestBody) => { return !this.#shouldIgnoreUploadPath(requestBody.fileMetadata.path); }); if (filteredRequests.length === 0) { res.json([]); return; } await this.#assertBatchWriteAccess( req, filteredRequests.map((requestBody) => requestBody.fileMetadata), { pathAlreadyNormalized: true }, ); const preparedBatch = await this.services.fs.prepareBatchWrites( userId, filteredRequests.map((requestBody) => ({ fileMetadata: requestBody.fileMetadata, thumbnailData: requestBody.thumbnailData, guiMetadata: requestBody.guiMetadata, })), storageAllowanceMax, ); await this.services.fs.assertStorageAllowanceForPreparedBatch( preparedBatch, undefined, storageAllowanceMax, ); const uploadResults = await runWithConcurrencyLimitSettled( filteredRequests, 8, async (requestBody, index) => { const preparedItem = preparedBatch.items[index]; if (!preparedItem) { throw new Error( `Failed to resolve prepared batch item for index ${index}`, ); } const uploadTracker = await this.#createUploadTracker( userId, preparedItem.objectKey, preparedItem.normalizedInput.path, preparedItem.normalizedInput.size, requestBody.guiMetadata, ); return this.services.fs.uploadPreparedBatchItem({ preparedBatch, itemIndex: preparedItem.index, fileContent: requestBody.fileContent, encoding: requestBody.encoding, uploadTracker, }); }, ); const uploadedItems = uploadResults .filter( ( result, ): result is PromiseFulfilledResult => result.status === 'fulfilled', ) .map((result) => result.value); const failedUpload = uploadResults.find( (result) => result.status === 'rejected', ); if (failedUpload?.status === 'rejected') { await this.services.fs.cleanupPreparedBatchUploads( preparedBatch, uploadedItems, ); throw failedUpload.reason instanceof Error ? failedUpload.reason : new Error('Failed to upload batch write item'); } const writeResponses = await this.services.fs.finalizePreparedBatchWrites( preparedBatch, uploadedItems, ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, async (writeResponse, index) => { const requestBody = filteredRequests[index]; return this.#applyWriteResponseSideEffects( userId, writeResponse, requestBody?.guiMetadata, ); }, ); res.json(updatedResponses); } // -- Read-side routes ------------------------------------------------ @Post('/stat', { subdomain: 'api', requireVerified: true }) async statEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const entry = await this.#resolveEntryForRequest(body); await this.#assertAccess(actor, entry.path, 'see'); const wantsSize = this.#toBoolean(body.return_size); const subtreeSize = entry.isDir && wantsSize ? await this.services.fs.getSubtreeSize(userId, entry.path) : undefined; entry.suggestedApps = await this.services.suggestedApps.getSuggestedApps(entry); res.json({ ...entry, ...(subtreeSize !== undefined ? { size: subtreeSize } : {}), }); } @Post('/readdir', { subdomain: 'api', requireVerified: true }) async readdirEntries(req: Request, res: Response) { const actor = this.#requireActor(req); const body = this.#toObjectRecord(req.body); if (this.#isRootPathRef(body)) { const { listRootEntries } = await import('../../services/fs/rootListing.js'); const rootChildren = await listRootEntries( actor, this.stores.fsEntry, this.services.permission, ); const rootSuggestions = await this.services.suggestedApps.getSuggestedAppsForEntries( rootChildren, ); for (let index = 0; index < rootChildren.length; index++) { const child = rootChildren[index]; if (child) { child.suggestedApps = rootSuggestions[index] ?? []; } } res.json(rootChildren); return; } const parent = await this.#resolveEntryForRequest(body); if (!parent.isDir) { throw new HttpError(400, 'Target is not a directory', { legacyCode: 'bad_request', }); } await this.#assertAccess(actor, parent.path, 'list'); const limit = this.#toNumberOrUndefined(body.limit); const offset = this.#toNumberOrUndefined(body.offset); const sortByRaw = typeof body.sort_by === 'string' ? body.sort_by.toLowerCase() : undefined; const sortBy = (['name', 'modified', 'type', 'size'] as const).find( (v) => v === sortByRaw, ) ?? null; const sortOrderRaw = typeof body.sort_order === 'string' ? body.sort_order.toLowerCase() : undefined; const sortOrder = (['asc', 'desc'] as const).find((v) => v === sortOrderRaw) ?? null; const children = await this.services.fs.listDirectory(parent.uuid, { limit, offset, sortBy, sortOrder, }); const suggestions = await this.services.suggestedApps.getSuggestedAppsForEntries( children, ); for (let index = 0; index < children.length; index++) { const child = children[index]; if (child) { child.suggestedApps = suggestions[index] ?? []; } } res.json(children); } @Post('/search', { subdomain: 'api', requireVerified: true }) async searchEntries(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const query = typeof body.query === 'string' ? body.query : typeof body.text === 'string' ? body.text : ''; if (query.trim().length === 0) { throw new HttpError(400, 'Missing `query`', { legacyCode: 'bad_request', }); } const limit = this.#toNumberOrUndefined(body.limit); const results = await this.services.fs.searchByName( userId, query, limit ?? 200, this.#appDataScopeForActor(actor), ); res.json(results); } @Get('/read', { subdomain: 'api', requireVerified: true }) async readEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const query = this.#toObjectRecord(req.query); const entry = await this.#resolveEntryForRequest(query); await this.#assertAccess(actor, entry.path, 'read'); if (entry.isDir) { throw new HttpError( 400, 'Cannot read a directory; use /fs/readdir', { legacyCode: 'cannot_read_a_directory' }, ); } const range = typeof req.headers.range === 'string' ? req.headers.range : undefined; const download = await this.services.fs.readContent(entry, { range, }); if (download.contentType) res.setHeader('Content-Type', download.contentType); if (download.contentLength !== null) res.setHeader('Content-Length', String(download.contentLength)); if (download.contentRange) res.setHeader('Content-Range', download.contentRange); if (download.etag) res.setHeader('ETag', download.etag); if (download.lastModified) res.setHeader('Last-Modified', download.lastModified.toUTCString()); res.setHeader( 'Content-Disposition', `inline; filename="${encodeURIComponent(entry.name)}"`, ); res.status(range ? 206 : 200); const metering = this.services.metering as | { batchIncrementUsages?: ( actor: unknown, entries: unknown[], ) => void; } | undefined; try { await pipeline(download.body, res); } catch { // Client disconnect or upstream stream error — pipeline already // tore down both ends. Response is partially sent; nothing to do. return; } // Meter egress only on successful completion. if (metering?.batchIncrementUsages && download.contentLength) { try { const bytes = download.contentLength; metering.batchIncrementUsages(actor, [ { usageType: 'filesystem:egress:bytes', usageAmount: bytes, costOverride: FS_COSTS['filesystem:egress:bytes'] * bytes, }, ]); } catch { // ignore — metering is non-critical. } } } // -- Mutation routes ------------------------------------------------ @Post('/mkdir', { subdomain: 'api', requireVerified: true }) async mkdirEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const rawPath = typeof body.path === 'string' ? body.path : ''; if (!rawPath.trim()) throw new HttpError(400, 'Missing `path`', { legacyCode: 'bad_request', }); // Normalize first: expands `~`, collapses `..`, ensures leading `/` // and no trailing `/`. Without this, parent-path derivation below // would compute a wrong parent for `~/...` inputs (e.g. dirname of // `/~/Documents/foo` is `/~/Documents`, not `//Documents`). const username = this.#getActorUsername(req); const path = this.#normalizePath(rawPath, username); if (path === '/') throw new HttpError(400, 'Cannot mkdir at root', { legacyCode: 'bad_request', }); await this.#assertCanCreate(actor, path); const entry = await this.services.fs.mkdir(userId, { path, overwrite: this.#toBoolean(body.overwrite) ?? false, dedupeName: this.#toBoolean(body.dedupe_name ?? body.dedupeName) ?? false, createMissingParents: this.#toBoolean( body.create_missing_parents ?? body.create_missing_ancestors, ) ?? false, }); this.#emitGuiItemAdded(entry); res.json(entry); } @Post('/touch', { subdomain: 'api', requireVerified: true }) async touchEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const rawPath = typeof body.path === 'string' ? body.path : ''; if (!rawPath.trim()) throw new HttpError(400, 'Missing `path`', { legacyCode: 'bad_request', }); const username = this.#getActorUsername(req); const path = this.#normalizePath(rawPath, username); if (path === '/') throw new HttpError(400, 'Cannot touch root', { legacyCode: 'bad_request', }); const parentPath = pathPosix.dirname(path); await this.#assertAccess( actor, parentPath === '/' ? path : parentPath, 'write', ); const entry = await this.services.fs.touch(userId, { path, setAccessed: this.#toBoolean(body.set_accessed_to_now) ?? false, setModified: this.#toBoolean(body.set_modified_to_now) ?? false, setCreated: this.#toBoolean(body.set_created_to_now) ?? false, createMissingParents: this.#toBoolean(body.create_missing_parents) ?? false, }); res.json(entry); } @Post('/rename', { subdomain: 'api', requireVerified: true }) async renameEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const body = this.#toObjectRecord(req.body); const newName = typeof body.new_name === 'string' ? body.new_name : ''; if (!newName.trim()) throw new HttpError(400, 'Missing `new_name`', { legacyCode: 'bad_request', }); const entry = await this.#resolveEntryForRequest(body); await this.#assertAccess(actor, entry.path, 'write'); const renamed = await this.services.fs.rename(entry, newName); this.#emitGuiItemUpdated(renamed); res.json(renamed); } @Post('/delete', { subdomain: 'api', requireVerified: true }) async deleteEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const entry = await this.#resolveEntryForRequest(body); await this.#assertAccess(actor, entry.path, 'write'); const descendantsOnly = this.#toBoolean(body.descendants_only) ?? false; await this.services.fs.remove(userId, { entry, recursive: this.#toBoolean(body.recursive) ?? false, descendantsOnly, }); this.#emitGuiItemRemoved(entry, descendantsOnly); res.json({ ok: true }); } @Post('/move', { subdomain: 'api', requireVerified: true }) async moveEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const sourceRef = this.#extractNodeRef(body.source ?? body); const destinationRef = this.#extractNodeRef(body.destination); const source = await this.#resolveEntryForRequest(sourceRef); const destinationParent = await this.#resolveEntryForRequest(destinationRef); await this.#assertAccess(actor, source.path, 'write'); await this.#assertAccess(actor, destinationParent.path, 'write'); const moved = await this.services.fs.move(userId, { source, destinationParent, newName: typeof body.new_name === 'string' ? body.new_name : undefined, overwrite: this.#toBoolean(body.overwrite) ?? false, dedupeName: this.#toBoolean(body.dedupe_name ?? body.change_name) ?? false, }); this.#emitGuiItemMoved(source, moved); res.json(moved); } @Post('/copy', { subdomain: 'api', requireVerified: true }) async copyEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const sourceRef = this.#extractNodeRef(body.source ?? body); const destinationRef = this.#extractNodeRef(body.destination); const source = await this.#resolveEntryForRequest(sourceRef); const destinationParent = await this.#resolveEntryForRequest(destinationRef); await this.#assertAccess(actor, source.path, 'read'); await this.#assertAccess(actor, destinationParent.path, 'write'); const copy = await this.services.fs.copy(userId, { source, destinationParent, newName: typeof body.new_name === 'string' ? body.new_name : undefined, overwrite: this.#toBoolean(body.overwrite) ?? false, dedupeName: this.#toBoolean(body.dedupe_name ?? body.change_name) ?? true, }); this.#emitGuiItemAdded(copy); res.json(copy); } @Post('/mkshortcut', { subdomain: 'api', requireVerified: true }) async mkshortcutEntry(req: Request, res: Response) { const actor = this.#requireActor(req); const userId = this.#getActorUserId(req); const body = this.#toObjectRecord(req.body); const parentRef = this.#extractNodeRef(body.parent ?? body); const targetRef = this.#extractNodeRef(body.target); const name = typeof body.name === 'string' ? body.name : ''; if (!name.trim()) throw new HttpError(400, 'Missing `name`', { legacyCode: 'bad_request', }); const parent = await this.#resolveEntryForRequest(parentRef); const target = await this.#resolveEntryForRequest(targetRef); await this.#assertAccess(actor, target.path, 'read'); await this.#assertAccess(actor, parent.path, 'write'); const shortcut = await this.services.fs.mkshortcut(userId, { parent, name, target, dedupeName: this.#toBoolean(body.dedupe_name) ?? true, }); this.#emitGuiItemAdded(shortcut); res.json(shortcut); } // -- Read-side helpers ----------------------------------------------- #requireActor(req: Request): Actor { const actor = req.actor; if (!actor) { throw new HttpError(401, 'Unauthorized', { legacyCode: 'unauthorized', }); } return actor; } #isRootPathRef(source: Record): boolean { if (typeof source.path !== 'string') return false; if (source.uid !== undefined || source.uuid !== undefined) return false; if (source.id !== undefined) return false; return source.path.trim() === '/'; } async #resolveEntryForRequest(source: Record) { const mod = await import('../../services/fs/resolveNode.js'); const username = Context.get('actor')?.user?.username; const rawPath = typeof source.path === 'string' ? source.path : undefined; const ref = { path: rawPath !== undefined ? mod.expandTildePath(rawPath, username) : undefined, uid: typeof source.uid === 'string' ? source.uid : typeof source.uuid === 'string' ? source.uuid : undefined, id: typeof source.id === 'number' || typeof source.id === 'string' ? source.id : undefined, }; const entry = await mod.resolveNode(this.stores.fsEntry, ref, { required: true, }); if (!entry) { throw new HttpError(404, 'Entry not found', { legacyCode: 'not_found', }); } return entry; } /** * Authorize creation of a new entry at `targetPath`. The standard rule * is write on the parent, but we also accept write on the target itself * — this lets an app create its own `//AppData/` folder * (parent `AppData` is off-limits, but the target is the app's own * subtree per ACLService's short-circuit) and lets recipients of a * direct share on a not-yet-existent path materialize it. */ async #assertCanCreate(actor: Actor, targetPath: string) { const parent = pathPosix.dirname(targetPath); const parentForCheck = parent === '/' ? targetPath : parent; const fsService = this.services.fs; const makeDescriptor = (path: string) => { let cache: Promise> | null = null; return { path, resolveAncestors() { if (!cache) cache = fsService.getAncestorChain(path); return cache; }, }; }; if ( await this.services.acl.check( actor, makeDescriptor(parentForCheck), 'write', ) ) { return; } if ( await this.services.acl.check( actor, makeDescriptor(targetPath), 'write', ) ) { return; } await this.#assertAccess(actor, parentForCheck, 'write'); } async #assertAccess( actor: Actor, path: string, mode: 'see' | 'list' | 'read' | 'write', ) { const fsService = this.services.fs; let ancestorsCache: Promise< Array<{ uid: string; path: string }> > | null = null; const descriptor = { path, resolveAncestors() { if (!ancestorsCache) { ancestorsCache = fsService.getAncestorChain(path); } return ancestorsCache; }, }; const allowed = await this.services.acl.check(actor, descriptor, mode); if (allowed) return; const safe = (await this.services.acl.getSafeAclError( actor, descriptor, mode, )) as { status?: unknown; message?: unknown; fields?: { code?: unknown }; }; const status = Number(safe?.status); const message = typeof safe?.message === 'string' && safe.message.length > 0 ? safe.message : 'Access denied'; const code = typeof safe?.fields?.code === 'string' ? safe.fields.code : undefined; const legacyCode = code === 'forbidden' ? 'access_denied' : code; if (status === 404) { throw new HttpError(404, message, { ...(legacyCode ? { legacyCode } : {}), }); } throw new HttpError(403, message, { legacyCode: legacyCode ?? 'access_denied', }); } #toNumberOrUndefined(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim().length > 0) { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return undefined; } // Accepts loose inputs from route bodies. `source`/`destination` fields may // arrive as a plain string (= path) or an object of { path | uid | id }. #extractNodeRef(value: unknown): Record { if (typeof value === 'string') return { path: value }; if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } // Fire-and-forget GUI events for single-entry mutations. These feed the // desktop cache invalidator and extension listeners (e.g. thumbnails). #emitGuiItemAdded(entry: FSEntry): void { void this.#emitGuiWriteEvent( 'outer.gui.item.added', entry, undefined, ).catch(() => undefined); } #emitGuiItemUpdated(entry: FSEntry): void { void this.#emitGuiWriteEvent( 'outer.gui.item.updated', entry, undefined, ).catch(() => undefined); } #emitGuiItemRemoved(entry: FSEntry, descendantsOnly = false): void { // GUI listens for `outer.gui.item.removed`; same envelope shape. // `descendants_only` lets the GUI keep the parent (e.g. Trash) and // only drop its children — without it the GUI removes the parent too. void (async () => { try { await this.clients.event.emit( 'outer.gui.item.removed', { user_id_list: [entry.userId], response: { ...entry, from_new_service: true, descendants_only: descendantsOnly, }, }, {}, ); } catch { // ignore — non-critical. } })(); } #emitGuiItemMoved(source: FSEntry, moved: FSEntry): void { void (async () => { try { await this.clients.event.emit( 'outer.gui.item.moved', { user_id_list: [moved.userId], response: { ...moved, from_path: source.path, from_new_service: true, }, }, {}, ); } catch { // ignore — non-critical. } })(); } #getActorUserId(req: Request): number { const requestUser = ( req as Request & { user?: { id?: unknown; }; } ).user; const actorUser = req.actor?.user; const candidateUserId = requestUser?.id ?? actorUser?.id; if (candidateUserId === undefined || candidateUserId === null) { throw new HttpError(401, 'Unauthorized', { legacyCode: 'unauthorized', }); } const userId = Number(candidateUserId); if (Number.isNaN(userId)) { throw new HttpError(401, 'Unauthorized', { legacyCode: 'unauthorized', }); } return userId; } #getActorUsername(req: Request): string { const requestUser = ( req as Request & { user?: { username?: unknown; }; } ).user; const actorUser = req.actor?.user; const actorUsername = requestUser?.username ?? actorUser?.username; if ( typeof actorUsername !== 'string' || actorUsername.trim().length === 0 ) { throw new HttpError(401, 'Unauthorized', { legacyCode: 'unauthorized', }); } return actorUsername.trim(); } #toObjectRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return value as Record; } #firstDefined(...values: unknown[]): unknown { for (const value of values) { if (value !== undefined && value !== null) { return value; } } return undefined; } #toBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { if (value === 1) return true; if (value === 0) return false; return undefined; } if (typeof value === 'string') { const normalizedValue = value.trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalizedValue)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalizedValue)) { return false; } } return undefined; } #toNumber(value: unknown): number | undefined { if (value === undefined || value === null || value === '') { return undefined; } const candidate = Number(value); if (!Number.isFinite(candidate)) { return undefined; } return candidate; } #resolveWriteFileMetadata( fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, ): FSEntryWriteInput { const metadataRecord = this.#toObjectRecord(fileMetadata); const fallbackRecord = this.#toObjectRecord(fallbackSource); const normalizedFileMetadata: Record = { ...metadataRecord, }; const path = this.#firstDefined( metadataRecord.path, fallbackRecord.path, ); if (typeof path === 'string') { normalizedFileMetadata.path = path; } const size = this.#toNumber( this.#firstDefined(metadataRecord.size, fallbackRecord.size), ); if (size !== undefined) { normalizedFileMetadata.size = size; } const contentType = this.#firstDefined( metadataRecord.contentType, metadataRecord.content_type, fallbackRecord.contentType, fallbackRecord.content_type, ); if (typeof contentType === 'string' && contentType.length > 0) { normalizedFileMetadata.contentType = contentType; } const checksumSha256 = this.#firstDefined( metadataRecord.checksumSha256, metadataRecord.checksum_sha256, fallbackRecord.checksumSha256, fallbackRecord.checksum_sha256, ); if (typeof checksumSha256 === 'string' && checksumSha256.length > 0) { normalizedFileMetadata.checksumSha256 = checksumSha256; } const overwrite = this.#toBoolean( this.#firstDefined( metadataRecord.overwrite, fallbackRecord.overwrite, ), ); if (overwrite !== undefined) { normalizedFileMetadata.overwrite = overwrite; } const dedupeName = this.#toBoolean( this.#firstDefined( metadataRecord.dedupeName, metadataRecord.dedupe_name, fallbackRecord.dedupeName, fallbackRecord.dedupe_name, fallbackRecord.rename, fallbackRecord.change_name, ), ); if (dedupeName !== undefined) { normalizedFileMetadata.dedupeName = dedupeName; } const createMissingParents = this.#toBoolean( this.#firstDefined( metadataRecord.createMissingParents, metadataRecord.create_missing_parents, metadataRecord.create_missing_ancestors, fallbackRecord.createMissingParents, fallbackRecord.create_missing_parents, fallbackRecord.createMissingAncestors, fallbackRecord.create_missing_ancestors, fallbackRecord.createFileParent, fallbackRecord.create_file_parent, ), ); if (createMissingParents !== undefined) { normalizedFileMetadata.createMissingParents = createMissingParents; } const immutable = this.#toBoolean( this.#firstDefined( metadataRecord.immutable, fallbackRecord.immutable, ), ); if (immutable !== undefined) { normalizedFileMetadata.immutable = immutable; } const isPublic = this.#toBoolean( this.#firstDefined( metadataRecord.isPublic, metadataRecord.is_public, fallbackRecord.isPublic, fallbackRecord.is_public, ), ); if (isPublic !== undefined) { normalizedFileMetadata.isPublic = isPublic; } const multipartPartSize = this.#toNumber( this.#firstDefined( metadataRecord.multipartPartSize, metadataRecord.multipart_part_size, fallbackRecord.multipartPartSize, fallbackRecord.multipart_part_size, ), ); if (multipartPartSize !== undefined && multipartPartSize > 0) { normalizedFileMetadata.multipartPartSize = multipartPartSize; } const associatedAppId = this.#toNumber( this.#firstDefined( metadataRecord.associatedAppId, metadataRecord.associated_app_id, fallbackRecord.associatedAppId, fallbackRecord.associated_app_id, ), ); if (associatedAppId !== undefined) { normalizedFileMetadata.associatedAppId = associatedAppId; } return normalizedFileMetadata as unknown as FSEntryWriteInput; } async #resolveAssociatedAppMetadata( fileMetadata: FSEntryWriteInput, fallbackSource?: unknown, appUidLookupCache?: Map>, ): Promise { const metadataRecord = this.#toObjectRecord(fileMetadata); const fallbackRecord = this.#toObjectRecord(fallbackSource); const associatedAppId = this.#toNumber( this.#firstDefined( metadataRecord.associatedAppId, metadataRecord.associated_app_id, fallbackRecord.associatedAppId, fallbackRecord.associated_app_id, ), ); if (associatedAppId !== undefined) { return { ...fileMetadata, associatedAppId, }; } const appUid = this.#firstDefined( metadataRecord.appUID, metadataRecord.appUid, metadataRecord.app_uid, fallbackRecord.appUID, fallbackRecord.appUid, fallbackRecord.app_uid, ); if (typeof appUid !== 'string' || appUid.trim().length === 0) { return fileMetadata; } const normalizedAppUid = appUid.trim(); const lookupPromise = (() => { const cachedLookup = appUidLookupCache?.get(normalizedAppUid); if (cachedLookup) { return cachedLookup; } const createdLookupPromise = (async () => { const app = await this.stores.app.getByUid(normalizedAppUid); return this.#toNumber(app?.id) ?? null; })(); appUidLookupCache?.set(normalizedAppUid, createdLookupPromise); return createdLookupPromise; })(); const resolvedAppId = await lookupPromise; if (resolvedAppId === null) { return fileMetadata; } return { ...fileMetadata, associatedAppId: resolvedAppId, }; } #toStorageCapacityCandidate(value: unknown): number | undefined { const capacity = Number(value); if (!Number.isFinite(capacity) || capacity < 0) { return undefined; } return capacity; } #getStorageAllowanceMaxOverride(req: Request): number | undefined { // free_storage / actual_free_storage are user-row fields not on // the ActorUser type. Access via the escape hatch until a proper // storage-quota mechanism is in place. const actorUser = req.actor?.user as | Record | undefined; const candidates = [ this.#toStorageCapacityCandidate(actorUser?.free_storage), this.#toStorageCapacityCandidate(actorUser?.actual_free_storage), ].filter((candidate): candidate is number => candidate !== undefined); if (candidates.length === 0) { return undefined; } return Math.max(...candidates); } #normalizePath(path: string, username?: string): string { const trimmedPath = path.trim(); if (trimmedPath.length === 0) { throw new HttpError(400, 'Path cannot be empty', { legacyCode: 'bad_request', }); } let pathToNormalize = trimmedPath; if (pathToNormalize === '~' || pathToNormalize.startsWith('~/')) { if (!username) { throw new HttpError(400, 'Unable to resolve home path', { legacyCode: 'bad_request', }); } pathToNormalize = `/${username}${pathToNormalize.slice(1)}`; } assertNormalized(pathToNormalize); let normalizedPath = pathToNormalize; if (!normalizedPath.startsWith('/')) { normalizedPath = `/${normalizedPath}`; } if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) { normalizedPath = normalizedPath.slice(0, -1); } return normalizedPath; } #normalizeFileMetadataPath( req: Request, fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, ): FSEntryWriteInput { const resolvedFileMetadata = this.#resolveWriteFileMetadata( fileMetadata, fallbackSource, ); if (typeof resolvedFileMetadata.path !== 'string') { throw new HttpError(400, 'Missing path', { legacyCode: 'bad_request', }); } const username = this.#getActorUsername(req); return { ...resolvedFileMetadata, path: this.#normalizePath(resolvedFileMetadata.path, username), }; } #extractGuiMetadata( input: unknown, fallback: WriteGuiMetadata | undefined, ): WriteGuiMetadata | undefined { const source = input && typeof input === 'object' ? (input as Record) : {}; const guiMetadata: WriteGuiMetadata = { originalClientSocketId: typeof source.originalClientSocketId === 'string' ? source.originalClientSocketId : typeof source.original_client_socket_id === 'string' ? source.original_client_socket_id : fallback?.originalClientSocketId, socketId: typeof source.socketId === 'string' ? source.socketId : typeof source.socket_id === 'string' ? source.socket_id : fallback?.socketId, operationId: typeof source.operationId === 'string' ? source.operationId : typeof source.operation_id === 'string' ? source.operation_id : fallback?.operationId, itemUploadId: typeof source.itemUploadId === 'string' ? source.itemUploadId : typeof source.item_upload_id === 'string' ? source.item_upload_id : fallback?.itemUploadId, }; if ( !guiMetadata.originalClientSocketId && !guiMetadata.socketId && !guiMetadata.operationId && !guiMetadata.itemUploadId ) { return undefined; } return guiMetadata; } #withGuiMetadata( value: T, fallbackSource: unknown, ): T { const guiMetadata = this.#extractGuiMetadata( value, this.#extractGuiMetadata(fallbackSource, undefined), ); if (!guiMetadata) { return value; } return { ...value, guiMetadata, }; } async #assertWriteAccess( req: Request, fileMetadata: FSEntryWriteInput | undefined, options?: { pathAlreadyNormalized?: boolean; }, ): Promise { const actor = req.actor; if (!actor) { throw new HttpError(401, 'Unauthorized', { legacyCode: 'unauthorized', }); } const normalizedFileMetadata = options?.pathAlreadyNormalized ? fileMetadata : this.#normalizeFileMetadataPath(req, fileMetadata); if (!normalizedFileMetadata) { throw new HttpError(400, 'Missing path', { legacyCode: 'bad_request', }); } const targetPath = normalizedFileMetadata.path; if (targetPath === '/') { throw new HttpError(400, 'Cannot write to root path', { legacyCode: 'cannot_write_to_root', }); } const parentPath = pathPosix.dirname(targetPath); if (parentPath === '/') { throw new HttpError(400, 'Cannot write to root path', { legacyCode: 'cannot_write_to_root', }); } let pathToCheck = parentPath; if (Boolean(normalizedFileMetadata.overwrite)) { const destinationExists = await this.services.fs.entryExistsByPath(targetPath); if (destinationExists) { pathToCheck = targetPath; } } const fsService = this.services.fs; let ancestorsCache: Promise< Array<{ uid: string; path: string }> > | null = null; const resourceDescriptor = { path: pathToCheck, resolveAncestors() { if (!ancestorsCache) { ancestorsCache = fsService.getAncestorChain(pathToCheck); } return ancestorsCache; }, }; const canWrite = await this.services.acl.check( actor, resourceDescriptor, 'write', ); if (canWrite) { return; } const safeAclError = (await this.services.acl.getSafeAclError( actor, resourceDescriptor, 'write', )) as { status?: unknown; message?: unknown; fields?: { code?: unknown; }; }; const safeAclStatus = Number(safeAclError?.status); const safeAclMessage = typeof safeAclError?.message === 'string' && safeAclError.message.length > 0 ? safeAclError.message : 'Write access denied for destination'; const safeAclCode = typeof safeAclError?.fields?.code === 'string' ? safeAclError.fields.code : undefined; const legacyCode = safeAclCode === 'forbidden' ? 'access_denied' : safeAclCode; if (safeAclStatus === 404) { throw new HttpError(404, safeAclMessage, { ...(legacyCode ? { legacyCode } : {}), }); } throw new HttpError(403, safeAclMessage, { legacyCode: legacyCode ?? 'access_denied', }); } async #assertBatchWriteAccess( req: Request, fileMetadataItems: Array, options?: { pathAlreadyNormalized?: boolean; concurrency?: number; }, ): Promise { await runWithConcurrencyLimit( fileMetadataItems, options?.concurrency ?? DEFAULT_BATCH_ACL_CHECK_CONCURRENCY, async (fileMetadata) => { await this.#assertWriteAccess(req, fileMetadata, { pathAlreadyNormalized: options?.pathAlreadyNormalized, }); }, ); } #toEventGuiMetadata( guiMetadata: WriteGuiMetadata | undefined, includeOriginalClientSocketId = true, ): Record { if (!guiMetadata) { return {}; } return { ...(includeOriginalClientSocketId && guiMetadata.originalClientSocketId ? { original_client_socket_id: guiMetadata.originalClientSocketId, } : {}), ...(guiMetadata.socketId ? { socket_id: guiMetadata.socketId } : {}), ...(guiMetadata.operationId ? { operation_id: guiMetadata.operationId } : {}), ...(guiMetadata.itemUploadId ? { item_upload_id: guiMetadata.itemUploadId } : {}), }; } async #toGuiFsEntry(entry: FSEntry): Promise> { return toLegacyEntry(this.clients.event, entry); } async #emitGuiWriteEvent( eventName: 'outer.gui.item.added' | 'outer.gui.item.updated', fsEntry: FSEntry, guiMetadata: WriteGuiMetadata | undefined, ): Promise { const response = { ...(await this.#toGuiFsEntry(fsEntry)), ...this.#toEventGuiMetadata(guiMetadata), from_new_service: true, }; await this.clients.event.emit( eventName, { user_id_list: [fsEntry.userId], response, }, {}, ); } async #emitGuiPendingWriteEvent( userId: number, requestBody: SignedWriteRequest, response: SignedWriteResponse, ): Promise { const normalizedPath = this.#normalizePath( requestBody.fileMetadata.path, ); const pendingResponse = { id: response.objectKey, uid: response.objectKey, uuid: response.objectKey, path: normalizedPath, name: pathPosix.basename(normalizedPath), is_dir: false, content_type: response.contentType, size: Number(requestBody.fileMetadata.size), upload_id: response.sessionId, pending_upload: true, status: 'pending', ...this.#toEventGuiMetadata(requestBody.guiMetadata), from_new_service: true, }; await this.clients.event.emit( 'outer.gui.item.pending', { user_id_list: [userId], response: pendingResponse, }, {}, ); } #isAppDataPath(targetPath: string): boolean { const pathParts = targetPath.split('/').filter(Boolean); return pathParts.length >= 2 && pathParts[1] === 'AppData'; } // If `actor` is effectively scoped to an app (directly or through an // access-token issuer chain), return that app's AppData root under the // actor's user. Returns undefined for pure user actors. The store anchors // search results to this path so app actors can't see entries outside // their AppData via `/fs/search`. #appDataScopeForActor(actor: Actor): string | undefined { const app = effectiveActorApp(actor); if (!app) return undefined; const username = actor.user?.username; if (typeof username !== 'string' || username.length === 0) return undefined; return `/${username}/AppData/${app.uid}`; } #estimateDataUrlSize(dataUrl: string): number { const commaIndex = dataUrl.indexOf(','); const base64 = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); return Math.ceil((base64.length * 3) / 4); } #isOversizedThumbnailDataUrl(thumbnail: string): boolean { if (!thumbnail.startsWith('data:')) { return false; } return this.#estimateDataUrlSize(thumbnail) > MAX_THUMBNAIL_BYTES; } async #applyThumbnailAfterWrite( userId: number, fsEntry: FSEntry, requestedThumbnail: string | null | undefined, ): Promise { if (!requestedThumbnail || this.#isAppDataPath(fsEntry.path)) { return fsEntry; } if (this.#isOversizedThumbnailDataUrl(requestedThumbnail)) { return fsEntry; } const thumbnailPayload = { url: requestedThumbnail }; // emitAndWait — the thumbnails extension may rewrite `url` from a // data URL to an `s3://` pointer; plain `emit` races with the DB // update below. await this.clients.event.emitAndWait( 'thumbnail.created', thumbnailPayload, {}, ); const finalThumbnail = typeof thumbnailPayload.url === 'string' && thumbnailPayload.url.length > 0 ? thumbnailPayload.url : null; if (finalThumbnail === fsEntry.thumbnail || finalThumbnail === null) { return fsEntry; } return this.services.fs.updateEntryThumbnail( userId, fsEntry.uuid, finalThumbnail, ); } #toThumbnailPrepareItem( requestBody: SignedWriteRequest, index: number, ): ThumbnailUploadPrepareItem | null { if (requestBody.directory) { return null; } const thumbnailMetadata = requestBody.thumbnailMetadata; if (!thumbnailMetadata) { return null; } const contentType = typeof thumbnailMetadata.contentType === 'string' ? thumbnailMetadata.contentType.trim() : ''; if (!contentType) { throw new HttpError( 400, 'thumbnailMetadata.contentType is required for signed thumbnail upload', { legacyCode: 'bad_request' }, ); } if (thumbnailMetadata.size === undefined) { return null; } const size = Number(thumbnailMetadata.size); if (!Number.isFinite(size) || size < 0) { throw new HttpError( 400, 'thumbnailMetadata.size must be a non-negative number', { legacyCode: 'bad_request' }, ); } if (size > MAX_THUMBNAIL_BYTES) { return null; } return { index, contentType, size } as ThumbnailUploadPrepareItem; } async #attachSignedThumbnailUploadTargets( requests: SignedWriteRequest[], responses: SignedWriteResponse[], ): Promise { const prepareItems = requests .map((requestBody, index) => this.#toThumbnailPrepareItem(requestBody, index), ) .filter((item): item is ThumbnailUploadPrepareItem => Boolean(item), ); if (prepareItems.length === 0) { return; } const payload: ThumbnailUploadPreparePayload = { items: prepareItems.map( (item): ThumbnailUploadPrepareItem => ({ index: item.index, contentType: item.contentType, ...(item.size !== undefined ? { size: item.size } : {}), }) as ThumbnailUploadPrepareItem, ), }; // emitAndWait — listeners populate `uploadUrl` / `thumbnailUrl` on // each item; plain `emit` returns before the extension runs and we'd // read the payload back empty. await this.clients.event.emitAndWait( 'thumbnail.upload.prepare', payload, {}, ); for (const item of payload.items) { const response = responses[item.index]; if (!response) { throw new HttpError( 500, 'Failed to resolve signed thumbnail response target', { legacyCode: 'internal_error' }, ); } if ( typeof item.uploadUrl !== 'string' || item.uploadUrl.length === 0 ) { continue; } if ( typeof item.thumbnailUrl !== 'string' || item.thumbnailUrl.length === 0 ) { continue; } response.thumbnailUploadUrl = item.uploadUrl; response.thumbnailUrl = item.thumbnailUrl; } } #assertNoInlineSignedThumbnailData( thumbnailData: string | undefined, ): void { if (typeof thumbnailData !== 'string') { return; } if (thumbnailData.startsWith('data:')) { throw new HttpError( 400, 'Signed write completion does not accept inline thumbnail data. Upload thumbnail to signed URL and provide thumbnail URL.', { legacyCode: 'bad_request' }, ); } } #isMultipartRequest(req: Request): boolean { const contentType = req.headers['content-type']; if (typeof contentType !== 'string') { return false; } return contentType.includes('multipart/form-data'); } #resolveBatchWriteRequestMode(req: Request): 'multipart' | 'json' { if (this.#isMultipartRequest(req)) { return 'multipart'; } const contentTypeHeader = req.headers['content-type']; const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader.toLowerCase() : ''; if ( contentType.includes('application/json') || contentType.startsWith('text/plain;actually=json') ) { return 'json'; } throw new HttpError( 415, 'Unsupported content type for batchWrite. Use multipart/form-data or application/json.', { legacyCode: 'bad_request' }, ); } async #runNonCritical( work: () => Promise, operationName: string, ): Promise { try { await work(); } catch (error) { console.error( `prodfsv2 non-critical operation failed: ${operationName}`, error, ); } } async #createUploadTracker( userId: number, itemUid: string, itemPath: string, expectedSize: number, guiMetadata: WriteGuiMetadata | undefined, ): Promise { const uploadTracker = new UploadProgressTracker(); uploadTracker.setTotal(Math.max(0, expectedSize)); const context = Context.get(); if (!context) { return uploadTracker; } await this.clients.event.emit( 'fs.storage.upload-progress', { upload_tracker: uploadTracker, context, meta: { user_id: userId, userId: userId, item_uid: itemUid, item_path: itemPath, ...this.#toEventGuiMetadata(guiMetadata), }, }, {}, ); return uploadTracker; } async #emitWriteHashEvent( contentHashSha256: string | null | undefined, entryUuid: string, ): Promise { if (!contentHashSha256) { return; } await this.clients.event.emit( 'outer.fs.write-hash', { hash: contentHashSha256, uuid: entryUuid, }, {}, ); } async #applyWriteResponseSideEffects( userId: number, response: WriteResponse, guiMetadata: WriteGuiMetadata | undefined, ): Promise { let fsEntry = response.fsEntry; const hashEventPromise = this.#runNonCritical(async () => { await this.#emitWriteHashEvent( response.contentHashSha256, fsEntry.uuid, ); }, 'emitWriteHashEvent'); await this.#runNonCritical(async () => { fsEntry = await this.#applyThumbnailAfterWrite( userId, response.fsEntry, response.requestedThumbnail, ); }, 'applyThumbnailAfterWrite'); await this.#runNonCritical(async () => { await this.#emitGuiWriteEvent( response.wasOverwrite ? 'outer.gui.item.updated' : 'outer.gui.item.added', fsEntry, guiMetadata, ); }, 'emitGuiWriteEvent'); await hashEventPromise; return { ...response, fsEntry }; } #shouldIgnoreUploadPath(targetPath: string): boolean { return pathPosix.basename(targetPath).toLowerCase() === '.ds_store'; } #parseBatchWriteManifest( manifestRaw: string, fallbackGuiMetadata: WriteGuiMetadata | undefined, ): ParsedMultipartBatchManifest { let parsedManifest: unknown; try { parsedManifest = JSON.parse(manifestRaw); } catch { throw new HttpError(400, 'Batch write manifest is not valid JSON', { legacyCode: 'bad_request', }); } const manifest: BatchWriteManifest = Array.isArray(parsedManifest) ? { items: parsedManifest as BatchWriteManifestItem[] } : (parsedManifest as BatchWriteManifest); if ( !manifest || !Array.isArray(manifest.items) || manifest.items.length === 0 ) { throw new HttpError( 400, 'Batch write manifest must include a non-empty items array', { legacyCode: 'bad_request' }, ); } const manifestGuiMetadata = this.#extractGuiMetadata( manifest, fallbackGuiMetadata, ); const normalizedItems = manifest.items.map((item, orderIndex) => { if (!item || typeof item !== 'object') { throw new HttpError( 400, `Batch write manifest item at position ${orderIndex} is invalid`, { legacyCode: 'bad_request' }, ); } const candidateIndex = (item as { index?: number | string }).index ?? orderIndex; const index = Number(candidateIndex); if (!Number.isInteger(index) || index < 0) { throw new HttpError( 400, `Batch write manifest item index is invalid at position ${orderIndex}`, { legacyCode: 'bad_request' }, ); } if (!item.fileMetadata || typeof item.fileMetadata !== 'object') { throw new HttpError( 400, `Batch write manifest item ${index} is missing fileMetadata`, { legacyCode: 'bad_request' }, ); } return { index, fileMetadata: item.fileMetadata, thumbnailData: typeof item.thumbnailData === 'string' ? item.thumbnailData : undefined, guiMetadata: this.#extractGuiMetadata( item, manifestGuiMetadata, ), }; }); const seenIndexes = new Set(); const fieldIndexMap = new Map(); for (const item of normalizedItems) { if (seenIndexes.has(item.index)) { throw new HttpError( 409, `Batch write manifest has duplicate index ${item.index}`, { legacyCode: 'conflict' }, ); } seenIndexes.add(item.index); fieldIndexMap.set(String(item.index), item.index); fieldIndexMap.set(`file-${item.index}`, item.index); fieldIndexMap.set(`files[${item.index}]`, item.index); } return { items: normalizedItems, guiMetadata: manifestGuiMetadata, fieldIndexMap, ignoredItemIndexes: new Set(), }; } #resolveMultipartFileIndex( fieldName: string, fileOrderIndex: number, manifest: ParsedMultipartBatchManifest, ): number { const directMatch = manifest.fieldIndexMap.get(fieldName); if (directMatch !== undefined) { return directMatch; } if (/^\d+$/.test(fieldName)) { const parsedIndex = Number(fieldName); if (manifest.fieldIndexMap.get(String(parsedIndex)) !== undefined) { return parsedIndex; } } if (fieldName === 'file' || fieldName === 'files') { const itemAtPosition = manifest.items[fileOrderIndex]; if (itemAtPosition) { return itemAtPosition.index; } } const fallbackItem = manifest.items[fileOrderIndex]; if (fallbackItem) { return fallbackItem.index; } throw new HttpError( 400, `Batch write file part "${fieldName}" does not map to manifest metadata`, { legacyCode: 'bad_request' }, ); } }