@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
345 lines (343 loc) • 12.8 kB
JavaScript
import { useLogger } from "../logger/index.js";
import { getAxios } from "../request/index.js";
import { RESUMABLE_UPLOADS } from "../constants.js";
import { getStorage } from "../storage/index.js";
import emitter_default from "../emitter.js";
import { validateAccess } from "../permissions/modules/validate-access/validate-access.js";
import { transaction } from "../utils/transaction.js";
import { assertUniqueFilename } from "./files/lib/assert-unique-filename.js";
import { sanitizeFilepath } from "./files/lib/sanitize-filepath.js";
import { assertValidStoragePath } from "./files/lib/assert-valid-storage-path.js";
import { extractMetadata } from "./files/lib/extract-metadata.js";
import { isMimeTypeAllowed } from "./files/lib/is-mime-type-allowed.js";
import { ItemsService } from "./items.js";
import path from "path";
import { useEnv } from "@directus/env";
import { ContentTooLargeError, InternalServerError, InvalidPayloadError, ServiceUnavailableError } from "@directus/errors";
import { normalizePath, toArray, toBoolean } from "@directus/utils";
import { clone, cloneDeep } from "lodash-es";
import { extension } from "mime-types";
import { PassThrough, Transform } from "node:stream";
import zlib from "node:zlib";
import url from "url";
import formatTitle from "@directus/format-title";
import encodeURL from "encodeurl";
//#region src/services/files.ts
const env = useEnv();
const logger = useLogger();
var FilesService = class FilesService extends ItemsService {
constructor(options) {
super("directus_files", options);
}
/**
* Upload a single new file to the configured storage adapter
*/
async uploadOne(stream, data, primaryKey, opts) {
const storage = await getStorage();
let existingFile = null;
if (primaryKey !== void 0) existingFile = await this.knex.select("folder", "filename_download", "filename_disk", "title", "description", "metadata", "storage").from("directus_files").where({ id: primaryKey }).first() ?? null;
const payload = {
storage: toArray(env["STORAGE_LOCATIONS"])[0],
...existingFile ?? {},
...clone(data)
};
const disk = storage.location(payload.storage);
if ("folder" in payload === false) {
const settings = await this.knex.select("storage_default_folder").from("directus_settings").first();
if (settings?.storage_default_folder) payload.folder = settings.storage_default_folder;
}
const isReplacement = existingFile !== null && primaryKey !== void 0;
if (isReplacement === false || primaryKey === void 0) primaryKey = await this.createOne(payload, { emitEvents: false });
const fileExtension = path.extname(payload.filename_download) || payload.type && "." + extension(payload.type) || "";
const filenameDisk = primaryKey + (fileExtension || "");
payload.filename_disk ||= filenameDisk;
if (isReplacement === true && path.extname(payload.filename_disk) !== fileExtension) payload.filename_disk = filenameDisk;
const tempFilenameDisk = "temp_" + filenameDisk;
if (!payload.type) payload.type = "application/octet-stream";
const cleanUp = async () => {
try {
if (isReplacement === true) await disk.delete(tempFilenameDisk);
else {
await super.deleteMany([primaryKey]);
await disk.delete(payload.filename_disk);
}
} catch (err) {
if (isReplacement === true) logger.warn(`Couldn't delete temp file ${tempFilenameDisk}`);
else logger.warn(`Couldn't delete file ${payload.filename_disk}`);
logger.warn(err);
}
};
try {
if (isReplacement === true) await disk.write(tempFilenameDisk, stream, payload.type);
else await disk.write(payload.filename_disk, stream, payload.type);
if ("truncated" in stream && stream.truncated === true) throw new ContentTooLargeError();
} catch (err) {
logger.warn(`Couldn't save file ${payload.filename_disk}`);
logger.warn(err);
await cleanUp();
if (err instanceof ContentTooLargeError) throw err;
else if (err?.code && [
"EROFS",
"EACCES",
"EPERM"
].includes(err.code)) throw new InternalServerError();
else throw new ServiceUnavailableError({
service: "files",
reason: `Couldn't save file ${payload.filename_disk}`
});
}
if (isReplacement === true) try {
await this.updateOne(primaryKey, payload, { emitEvents: false });
for await (const filepath of disk.list(String(primaryKey))) await disk.delete(filepath);
await disk.move(tempFilenameDisk, payload.filename_disk);
} catch (err) {
await cleanUp();
throw err;
}
const { size } = await storage.location(payload.storage).stat(payload.filename_disk);
payload.filesize = size;
const metadata = await extractMetadata(payload.storage, payload);
payload.uploaded_on = (/* @__PURE__ */ new Date()).toISOString();
await new ItemsService("directus_files", {
knex: this.knex,
schema: this.schema
}).updateOne(primaryKey, {
...payload,
...metadata
}, { emitEvents: false });
if (opts?.emitEvents !== false) emitter_default.emitAction("files.upload", {
payload,
key: primaryKey,
collection: this.collection
}, {
database: this.knex,
schema: this.schema,
accountability: this.accountability
});
return primaryKey;
}
/**
* Extract metadata from a buffer's content
*/
/**
* Import a single file from an external URL
*/
async importOne(importURL, body, options = {}) {
if (this.accountability) await validateAccess({
accountability: this.accountability,
action: "create",
collection: "directus_files"
}, {
knex: this.knex,
schema: this.schema
});
let fileResponse;
try {
fileResponse = await (await getAxios()).get(encodeURL(importURL), {
responseType: "stream",
decompress: false
});
} catch (error) {
logger.warn(`Couldn't fetch file from URL "${importURL}"${error.message ? `: ${error.message}` : ""}`);
logger.trace(error);
throw new ServiceUnavailableError({
service: "external-file",
reason: `Couldn't fetch file from URL "${importURL}"`
});
}
let filename;
let mimeType;
try {
const parsedURL = url.parse(fileResponse.request.res.responseUrl);
filename = decodeURI(path.basename(parsedURL.pathname));
mimeType = fileResponse.headers["content-type"]?.split(";")[0]?.trim() || "application/octet-stream";
if (isMimeTypeAllowed(mimeType, env["FILES_MIME_TYPE_ALLOW_LIST"]) === false) throw new InvalidPayloadError({ reason: `File content type "${mimeType}" is not allowed for upload by your global file type restrictions` });
const { filterMimeType } = options;
if (filterMimeType && filterMimeType.length > 0 && isMimeTypeAllowed(mimeType, filterMimeType) === false) throw new InvalidPayloadError({ reason: `File content type "${mimeType}" is not allowed for upload by this field's file type restrictions` });
} catch (error) {
fileResponse.data.destroy();
throw error;
}
const payload = {
filename_download: filename,
type: mimeType,
title: formatTitle(filename),
...body || {}
};
return await this.uploadOne(decompressResponse(fileResponse.data, fileResponse.headers), payload, payload.id);
}
/**
* Create a file
*/
async createOne(data, opts = {}) {
if (!data.type) throw new InvalidPayloadError({ reason: `"type" is required` });
if (data.filename_disk) {
data.filename_disk = sanitizeFilepath(data.filename_disk);
try {
assertValidStoragePath(data.filename_disk, data.storage);
await assertUniqueFilename(this.knex, data.filename_disk);
} catch (err) {
opts.preMutationError = err;
}
}
return await super.createOne(data, opts);
}
/**
* Update many files
*/
async updateMany(keys, data, opts = {}) {
if (keys.length === 1 && data.filename_disk) {
data.filename_disk = sanitizeFilepath(data.filename_disk);
try {
assertValidStoragePath(data.filename_disk, data.storage);
await assertUniqueFilename(this.knex, data.filename_disk, keys[0]);
} catch (err) {
opts.preMutationError = err;
}
const sudoFilesItemsService = new FilesService({
knex: this.knex,
schema: this.schema
});
const updatedFiles = /* @__PURE__ */ new Map();
const changedFiles = await sudoFilesItemsService.readMany(keys, { fields: [
"id",
"storage",
"filename_disk"
] });
for (const file of changedFiles) updatedFiles.set(file.id, file);
for (const key of keys) await transaction(this.knex, async (trx) => {
await new ItemsService(this.collection, {
knex: trx,
schema: this.schema,
accountability: this.accountability
}).updateMany([key], data, opts);
if (data.filename_disk) {
const storage = await getStorage();
const file = updatedFiles.get(key);
if (!file || !file.filename_disk) return;
const existingFilePath = sanitizeFilepath(file.filename_disk);
if (existingFilePath === data.filename_disk) return;
const disk = storage.location(file["storage"]);
const { name: filePrefix, dir: fileDir } = path.parse(existingFilePath);
const updatedFilePath = sanitizeFilepath(data.filename_disk);
let remoteFileExists;
try {
remoteFileExists = await disk.exists(data.filename_disk);
} catch (error) {
throw new ServiceUnavailableError({
service: "files",
reason: `Couldn't reach the storage location`
}, { cause: error });
}
const filePrefixPath = fileDir ? normalizePath(path.join(fileDir, filePrefix)) : filePrefix;
for await (const filePath of disk.list(filePrefixPath)) {
/**
* If the remote file exists, repoint the primary asset to it (i.e. db update only).
* If the remote file does not exist, move the primary asset to location.
*
* NOTE
* - On repoint the original asset will be deleted if `FILES_DELETE_ORIGINAL_ON_MOVE` is true.
* - Any associated generated assets are deleted.
*/
if (filePath === existingFilePath) {
if (!remoteFileExists) {
await disk.move(filePath, updatedFilePath);
continue;
} else if (toBoolean(env["FILES_DELETE_ORIGINAL_ON_MOVE"]) === false) continue;
}
await disk.delete(filePath);
}
}
});
return keys;
}
if (keys.length > 1 && data.filename_disk) opts.preMutationError = new InvalidPayloadError({ reason: "\"filename_disk\" cannot be modified in bulk operations" });
await super.updateMany(keys, data, opts);
return keys;
}
/**
* Delete multiple files
*/
async deleteMany(keys) {
const storage = await getStorage();
const files = await new FilesService({
knex: this.knex,
schema: this.schema
}).readMany(keys, {
fields: [
"id",
"storage",
"filename_disk"
],
limit: -1
});
await super.deleteMany(keys);
for (const file of files) {
const disk = storage.location(file["storage"]);
const filePrefix = path.parse(file["filename_disk"]).name;
for await (const filepath of disk.list(filePrefix)) await disk.delete(filepath);
}
return keys;
}
async readByQuery(query, opts) {
const filteredQuery = cloneDeep(query);
if (RESUMABLE_UPLOADS.ENABLED === true) {
const filterPartialUploads = { tus_id: { _null: true } };
if (!filteredQuery.filter) filteredQuery.filter = filterPartialUploads;
else if ("_and" in filteredQuery.filter && Array.isArray(filteredQuery.filter["_and"])) filteredQuery.filter["_and"].push(filterPartialUploads);
else filteredQuery.filter = { _and: [filteredQuery.filter, filterPartialUploads] };
}
return super.readByQuery(filteredQuery, opts);
}
};
function decompressResponse(stream, headers) {
const contentEncoding = (headers["content-encoding"] || "").toLowerCase();
if (![
"gzip",
"deflate",
"br"
].includes(contentEncoding)) return stream;
let isEmpty$1 = true;
const checker = new Transform({
transform(data, _encoding, callback) {
if (isEmpty$1 === false) {
callback(null, data);
return;
}
isEmpty$1 = false;
handleContentEncoding(data);
callback(null, data);
},
flush(callback) {
callback();
}
});
const finalStream = new PassThrough({
autoDestroy: false,
destroy(error, callback) {
stream.destroy();
callback(error);
}
});
stream.pipe(checker);
return finalStream;
function handleContentEncoding(data) {
let decompressStream;
if (contentEncoding === "br") decompressStream = zlib.createBrotliDecompress();
else if (contentEncoding === "deflate" && isDeflateAlgorithm(data)) decompressStream = zlib.createInflateRaw();
else decompressStream = zlib.createUnzip();
decompressStream.once("error", (error) => {
if (isEmpty$1 && !stream.readable) {
finalStream.end();
return;
}
finalStream.destroy(error);
});
checker.pipe(decompressStream).pipe(finalStream);
}
function isDeflateAlgorithm(data) {
return data.length > 0 && (data[0] & 8) === 0;
}
}
//#endregion
export { FilesService };