/**
* 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' },
);
}
}