mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
npm run lint:promises runs typescript-eslint's no-floating-promises and no-misused-promises over models/lib, server/lib and imports/lib, with type information read from tsconfig.lint.json (nothing is type-checked). The eleven findings were promises that never reject (their own catch) and now say so with void, and three checks that used a promise as a boolean. Thanks to xet7 !
1355 lines
52 KiB
JavaScript
1355 lines
52 KiB
JavaScript
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { Random } from 'meteor/random';
|
|
import Attachments from '/models/attachments';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { PassThrough } from 'stream';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { createObjectId } from './grid/createObjectId';
|
|
import { httpStreamOutput } from './httpStream.js';
|
|
import {
|
|
STORAGE_NAME_FILESYSTEM,
|
|
STORAGE_NAME_GRIDFS,
|
|
STORAGE_NAME_S3,
|
|
STORAGE_NAME_AZURE,
|
|
STORAGE_NAME_GCS,
|
|
CLOUD_STORAGE_NAMES,
|
|
} from './fileStoreConstants';
|
|
import { getCloudAdapter, isCloudConfigured } from './cloudStorage';
|
|
import { ObjectId } from 'bson';
|
|
const { isFilesRoot } = require('./filesRoot');
|
|
|
|
// Re-export constants from shared module (keeps existing import paths working)
|
|
export {
|
|
STORAGE_NAME_FILESYSTEM,
|
|
STORAGE_NAME_GRIDFS,
|
|
STORAGE_NAME_S3,
|
|
STORAGE_NAME_AZURE,
|
|
STORAGE_NAME_GCS,
|
|
CLOUD_STORAGE_NAMES,
|
|
} from './fileStoreConstants';
|
|
|
|
// Pure filename helpers (path traversal + length cap) live in a Meteor-free
|
|
// module so they can be unit tested directly with Node. See
|
|
// models/lib/filenameSanitizer.js (#6412).
|
|
const { sanitizeFilename } = require('./filenameSanitizer');
|
|
const { hasEnoughDiskSpace } = require('./diskSpace');
|
|
|
|
// GHSA-4mxf-m8pq-xc9p: this containment check used to be written out here and
|
|
// nowhere else, so the board exporter - which reads the very same stored paths -
|
|
// had none. It lives in models/lib/storagePathContainment.js now, where the
|
|
// exporter uses it too and where plain Node can test it.
|
|
const { isPathInsideBase: isPathInside } = require('./storagePathContainment');
|
|
|
|
function tryRealPath(inputPath) {
|
|
try {
|
|
return fs.realpathSync(inputPath);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* `literalRootPath` is the storage root as WRITTEN; `storageRootPath` is that
|
|
* root with every symlink resolved. Both are needed, and comparing a path
|
|
* against the wrong one of them is what broke reading here.
|
|
*
|
|
* The candidates are built by joining names onto the LITERAL root, so the cheap
|
|
* lexical pre-filter below has to use that same root. It used to compare them
|
|
* against the RESOLVED one, which is identical on a system where nothing in the
|
|
* path is a symlink and different everywhere else: on macOS `os.tmpdir()` is
|
|
* `/var/folders/...`, a symlink to `/private/var/folders/...`, so every
|
|
* candidate was rejected before it was ever looked at and WeKan served none of
|
|
* its own attachments. A deployment whose data directory is a symlink - a very
|
|
* ordinary arrangement - has the same fault on Linux.
|
|
*
|
|
* The security property is unchanged, because it was never the lexical check
|
|
* that carried it: what a caller must not be able to do is reach a file OUTSIDE
|
|
* the storage root, and that is decided below by resolving the candidate and
|
|
* requiring the result to be inside the RESOLVED root. That check is what
|
|
* refuses `../../etc/passwd` and a symlink pointing out of the tree
|
|
* (GHSA-4mxf-m8pq-xc9p), and it still runs on every candidate.
|
|
*/
|
|
function isSafeReadableFile(candidatePath, storageRootPath, literalRootPath) {
|
|
if (!candidatePath || !storageRootPath) {
|
|
return false;
|
|
}
|
|
|
|
const lexicalRoot = literalRootPath || storageRootPath;
|
|
if (!isPathInside(lexicalRoot, candidatePath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const candidateRealPath = tryRealPath(candidatePath) || candidatePath;
|
|
if (!isPathInside(storageRootPath, candidateRealPath)) {
|
|
return false;
|
|
}
|
|
|
|
const stats = fs.statSync(candidateRealPath);
|
|
return stats.isFile();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Factory for FileStoreStrategy */
|
|
export default class FileStoreStrategyFactory {
|
|
|
|
/** constructor
|
|
* @param classFileStoreStrategyFilesystem use this strategy for filesystem storage
|
|
* @param storagePath file storage path
|
|
* @param classFileStoreStrategyGridFs use this strategy for GridFS storage
|
|
* @param gridFsBucket use this GridFS Bucket as GridFS Storage
|
|
* @param classFileStoreStrategyCloud strategy class for cloud backends (S3/Azure/GCS), optional
|
|
*/
|
|
constructor(classFileStoreStrategyFilesystem, storagePath, classFileStoreStrategyGridFs, gridFsBucket, classFileStoreStrategyCloud, collection) {
|
|
this.classFileStoreStrategyFilesystem = classFileStoreStrategyFilesystem;
|
|
this.storagePath = storagePath;
|
|
this.classFileStoreStrategyGridFs = classFileStoreStrategyGridFs;
|
|
this.gridFsBucket = gridFsBucket;
|
|
// Single strategy class handling every cloud provider (S3/Azure/GCS) via
|
|
// @tweedegolf/storage-abstraction. Optional: when omitted (or the npm
|
|
// packages are absent) cloud storage falls back to the filesystem.
|
|
this.classFileStoreStrategyCloud = classFileStoreStrategyCloud;
|
|
// The FilesCollection (Attachments or Avatars) whose documents this factory
|
|
// operates on. Strategies persist storage/path changes to this collection so
|
|
// the same code can move both attachments and avatars. Defaults to the
|
|
// global Attachments for backward compatibility.
|
|
this.collection = collection;
|
|
}
|
|
|
|
/** returns the right FileStoreStrategy
|
|
* @param fileObj the current file object
|
|
* @param versionName the current version
|
|
* @param use this storage, or if not set, get the storage from fileObj
|
|
*/
|
|
getFileStrategy(fileObj, versionName, storage) {
|
|
if (!storage) {
|
|
// Every one of these can be absent, and each absence used to throw here.
|
|
// A freshly uploaded file has no `meta` on its version (only a GridFS one
|
|
// does), so `versions[v].meta.gridFsFileId` threw "Cannot read properties
|
|
// of undefined (reading 'gridFsFileId')" on EVERY upload - which
|
|
// onAfterUpload caught and logged as "filename hardening failed", so the
|
|
// mime detection and filename correction were skipped for every new file
|
|
// while the upload itself appeared to succeed. `fileObj.meta` can be
|
|
// absent in the same way, and so can the version itself when a caller asks
|
|
// for one this file does not have.
|
|
const version = (fileObj && fileObj.versions && fileObj.versions[versionName]) || {};
|
|
const versionMeta = version.meta || {};
|
|
const fileMeta = (fileObj && fileObj.meta) || {};
|
|
storage = version.storage;
|
|
if (!storage) {
|
|
if (fileMeta.source == "import" || versionMeta.gridFsFileId) {
|
|
// uploaded by import, so it's in GridFS (MongoDB)
|
|
storage = STORAGE_NAME_GRIDFS;
|
|
} else {
|
|
// newly uploaded, so it's at the filesystem
|
|
storage = STORAGE_NAME_FILESYSTEM;
|
|
}
|
|
}
|
|
}
|
|
let ret;
|
|
if (storage == STORAGE_NAME_FILESYSTEM) {
|
|
ret = new this.classFileStoreStrategyFilesystem(fileObj, versionName, this.collection);
|
|
} else if (storage == STORAGE_NAME_GRIDFS) {
|
|
ret = new this.classFileStoreStrategyGridFs(this.gridFsBucket, fileObj, versionName, this.collection);
|
|
} else if (CLOUD_STORAGE_NAMES.includes(storage)) {
|
|
if (this.classFileStoreStrategyCloud && isCloudConfigured(storage)) {
|
|
ret = new this.classFileStoreStrategyCloud(storage, fileObj, versionName, this.collection);
|
|
} else {
|
|
// Cloud strategy unavailable or provider not configured — fall back to
|
|
// filesystem so reads of files that never actually reached the cloud
|
|
// still resolve instead of throwing.
|
|
console.warn(`Cloud storage "${storage}" is not configured; falling back to filesystem storage`);
|
|
ret = new this.classFileStoreStrategyFilesystem(fileObj, versionName, this.collection);
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
/** Strategy to store files */
|
|
class FileStoreStrategy {
|
|
|
|
/** constructor
|
|
* @param fileObj the current file object
|
|
* @param versionName the current version
|
|
*/
|
|
constructor(fileObj, versionName, collection) {
|
|
this.fileObj = fileObj;
|
|
this.versionName = versionName;
|
|
// FilesCollection to persist changes to (Attachments or Avatars). Falls
|
|
// back to the global Attachments when not supplied.
|
|
this.collection = collection || Attachments;
|
|
}
|
|
|
|
/** The record of this version, or {} when the file has none: a record
|
|
* written without `versions` (an import, a hand-made document) made every
|
|
* reader throw "reading 'original'", and the file route answered 500 for
|
|
* what is a missing file - a 404.
|
|
*/
|
|
version() {
|
|
const versions = (this.fileObj && this.fileObj.versions) || {};
|
|
return versions[this.versionName] || {};
|
|
}
|
|
|
|
/** after successfull upload */
|
|
onAfterUpload() {
|
|
}
|
|
|
|
/** download the file
|
|
* @param http the current http request
|
|
* @param cacheControl cacheControl of FilesCollection
|
|
*
|
|
* The BASE implementation refuses, and that is deliberate. An empty body
|
|
* returns undefined, which Meteor-Files reads as "not handled" and answers by
|
|
* serving the file itself - from the stored path, with the stored
|
|
* Content-Type, and none of the policy httpStreamOutput applies. Every
|
|
* concrete strategy below overrides this, so the default is only ever reached
|
|
* by one that forgot to; a strategy that forgets should serve nothing rather
|
|
* than serve it unfiltered.
|
|
*/
|
|
interceptDownload(http, cacheControl) {
|
|
http.response.statusCode = 404;
|
|
http.response.end('not found');
|
|
return true;
|
|
}
|
|
|
|
/** after file remove */
|
|
onAfterRemove() {
|
|
}
|
|
|
|
/** returns a read stream
|
|
* @return the read stream
|
|
*/
|
|
getReadStream() {
|
|
}
|
|
|
|
/** returns a write stream
|
|
* @param filePath if set, use this path
|
|
* @return the write stream
|
|
*/
|
|
getWriteStream(filePath) {
|
|
}
|
|
|
|
/** writing finished
|
|
* @param finishedData the data of the write stream finish event
|
|
*/
|
|
writeStreamFinished(finishedData) {
|
|
}
|
|
|
|
/** returns the new file path
|
|
* @param storagePath use this storage path
|
|
* @return the new file path
|
|
*/
|
|
getNewPath(storagePath, name) {
|
|
if (typeof name !== 'string') {
|
|
name = this.fileObj.name;
|
|
}
|
|
// Sanitize filename to prevent path traversal attacks
|
|
const safeName = sanitizeFilename(name);
|
|
const ret = path.join(storagePath, this.fileObj._id + "-" + this.versionName + "-" + safeName);
|
|
return ret;
|
|
}
|
|
|
|
/** remove the file */
|
|
unlink() {
|
|
}
|
|
|
|
/** rename the file (physical)
|
|
* @li at database the filename is updated after this method
|
|
* @param newFilePath the new file path
|
|
*/
|
|
rename(newFilePath) {
|
|
}
|
|
|
|
/** return the storage name
|
|
* @return the storage name
|
|
*/
|
|
getStorageName() {
|
|
}
|
|
}
|
|
|
|
/** Strategy to store attachments at GridFS (MongoDB) */
|
|
export class FileStoreStrategyGridFs extends FileStoreStrategy {
|
|
|
|
/** constructor
|
|
* @param gridFsBucket use this GridFS Bucket
|
|
* @param fileObj the current file object
|
|
* @param versionName the current version
|
|
*/
|
|
constructor(gridFsBucket, fileObj, versionName, collection) {
|
|
super(fileObj, versionName, collection);
|
|
this.gridFsBucket = gridFsBucket;
|
|
}
|
|
|
|
/** download the file
|
|
* @param http the current http request
|
|
* @param cacheControl cacheControl of FilesCollection
|
|
*/
|
|
/*
|
|
* FAIL CLOSED when the strategy will not serve the file.
|
|
*
|
|
* Returning false here means "not handled", and Meteor-Files then serves the
|
|
* file ITSELF - from the stored path, with the stored Content-Type, and none
|
|
* of the policy httpStreamOutput applies. A stored attachment of type
|
|
* text/html came back as text/html, status 200, inline: precisely the stored
|
|
* XSS that policy exists to stop.
|
|
*
|
|
* What makes that a bypass rather than a fallback is WHY the strategy
|
|
* declines. getReadStream() returns nothing when the file cannot be resolved
|
|
* INSIDE the storage root - the containment check of GHSA-4mxf-m8pq-xc9p - so
|
|
* "false" means "this is not a file WeKan may serve", and handing it to a
|
|
* server with no containment check answers the refusal with the file.
|
|
*
|
|
* A file that is genuinely absent gets the same 404 it would have got anyway.
|
|
*/
|
|
interceptDownload(http, cacheControl) {
|
|
const readStream = this.getReadStream();
|
|
const downloadFlag = http?.params?.query?.download;
|
|
|
|
if (!readStream) {
|
|
http.response.statusCode = 404;
|
|
http.response.end('not found');
|
|
return true;
|
|
}
|
|
|
|
httpStreamOutput(readStream, this.fileObj.name, http, downloadFlag, cacheControl, this.fileObj);
|
|
return true;
|
|
}
|
|
|
|
/** after file remove */
|
|
onAfterRemove() {
|
|
this.unlink();
|
|
super.onAfterRemove();
|
|
}
|
|
|
|
/** returns a read stream
|
|
* @return the read stream
|
|
*/
|
|
getReadStream(range) {
|
|
const gfsId = this.getGridFsObjectId();
|
|
let ret;
|
|
if (gfsId) {
|
|
// #6745: a byte range (inclusive end, models/lib/httpRange.js) streams
|
|
// only those bytes; GridFS takes an exclusive end.
|
|
ret = range
|
|
? this.gridFsBucket.openDownloadStream(gfsId, { start: range.start, end: range.end + 1 })
|
|
: this.gridFsBucket.openDownloadStream(gfsId);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/** returns a write stream
|
|
* @param filePath if set, use this path
|
|
* @return the write stream
|
|
*/
|
|
getWriteStream(filePath) {
|
|
const fileObj = this.fileObj;
|
|
const versionName = this.versionName;
|
|
const metadata = { ...fileObj.meta, versionName, fileId: fileObj._id };
|
|
const ret = this.gridFsBucket.openUploadStream(this.fileObj.name, {
|
|
contentType: fileObj.type || 'binary/octet-stream',
|
|
metadata,
|
|
});
|
|
// Keep a reference so writeStreamFinished can read the uploaded file's id
|
|
// from the stream — the mongodb driver's 'finish' event no longer passes the
|
|
// stored file document.
|
|
this.gridFsWriteStream = ret;
|
|
return ret;
|
|
}
|
|
|
|
/** writing finished
|
|
* @param finishedData the data of the write stream finish event
|
|
*/
|
|
writeStreamFinished(finishedData) {
|
|
const gridFsFileIdName = this.getGridFsFileIdName();
|
|
// Older mongodb drivers passed the stored file document to the 'finish'
|
|
// event; current drivers emit it with no argument (so finishedData is
|
|
// undefined and `finishedData._id` threw, crashing the server). Resolve the
|
|
// GridFS file id from the finish data when present, otherwise from the write
|
|
// stream itself (`id` is assigned by openUploadStream; `gridFSFile._id` is
|
|
// set once the upload finishes).
|
|
const stream = this.gridFsWriteStream;
|
|
const gridFsId =
|
|
(finishedData && finishedData._id) ||
|
|
(stream && stream.gridFSFile && stream.gridFSFile._id) ||
|
|
(stream && stream.id);
|
|
if (!gridFsId) {
|
|
console.error(
|
|
'GridFS write finished but no file id was available for',
|
|
this.fileObj && this.fileObj._id,
|
|
);
|
|
return;
|
|
}
|
|
const hexId =
|
|
typeof gridFsId.toHexString === 'function' ? gridFsId.toHexString() : String(gridFsId);
|
|
this.collection.updateAsync(
|
|
{ _id: this.fileObj._id },
|
|
{ $set: { [gridFsFileIdName]: hexId } },
|
|
).catch(error => {
|
|
console.error('Failed to persist GridFS file id:', error);
|
|
});
|
|
}
|
|
|
|
/** remove the file */
|
|
unlink() {
|
|
const gfsId = this.getGridFsObjectId();
|
|
if (gfsId) {
|
|
this.gridFsBucket.delete(gfsId, err => {
|
|
if (err) {
|
|
console.error("error on gfs bucket.delete: ", err);
|
|
}
|
|
});
|
|
}
|
|
|
|
const gridFsFileIdName = this.getGridFsFileIdName();
|
|
this.collection.updateAsync(
|
|
{ _id: this.fileObj._id },
|
|
{ $unset: { [gridFsFileIdName]: 1 } },
|
|
).catch(error => {
|
|
console.error('Failed to clear GridFS file id:', error);
|
|
});
|
|
}
|
|
|
|
/** return the storage name
|
|
* @return the storage name
|
|
*/
|
|
getStorageName() {
|
|
return STORAGE_NAME_GRIDFS;
|
|
}
|
|
|
|
/** returns the GridFS Object-Id
|
|
* @return the GridFS Object-Id
|
|
*/
|
|
getGridFsObjectId() {
|
|
let ret;
|
|
const gridFsFileId = this.getGridFsFileId();
|
|
if (gridFsFileId) {
|
|
ret = createObjectId({ gridFsFileId });
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/** returns the GridFS Object-Id
|
|
* @return the GridFS Object-Id
|
|
*/
|
|
getGridFsFileId() {
|
|
const ret = (this.version().meta || {})
|
|
.gridFsFileId;
|
|
return ret;
|
|
}
|
|
|
|
/** returns the property name of gridFsFileId
|
|
* @return the property name of gridFsFileId
|
|
*/
|
|
getGridFsFileIdName() {
|
|
const ret = `versions.${this.versionName}.meta.gridFsFileId`;
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
/** Strategy to store attachments at filesystem */
|
|
export class FileStoreStrategyFilesystem extends FileStoreStrategy {
|
|
|
|
/** constructor
|
|
* @param fileObj the current file object
|
|
* @param versionName the current version
|
|
*/
|
|
constructor(fileObj, versionName, collection) {
|
|
super(fileObj, versionName, collection);
|
|
}
|
|
|
|
/** Serve filesystem files through WeKan's response backstop instead of
|
|
* falling through to Meteor-Files, which trusts the stored MIME type and
|
|
* defaults to inline disposition. */
|
|
interceptDownload(http, cacheControl) {
|
|
const readStream = this.getReadStream();
|
|
// Fail closed, for the reason given above the filesystem strategy's
|
|
// interceptDownload: `false` hands the file to Meteor-Files unfiltered.
|
|
if (!readStream) {
|
|
http.response.statusCode = 404;
|
|
http.response.end('not found');
|
|
return true;
|
|
}
|
|
const downloadFlag = http?.params?.query?.download;
|
|
httpStreamOutput(
|
|
readStream,
|
|
this.fileObj.name,
|
|
http,
|
|
downloadFlag,
|
|
cacheControl,
|
|
this.fileObj,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/** returns a read stream
|
|
* @return the read stream
|
|
*/
|
|
/** every place the physical file could be, in priority order
|
|
*
|
|
* The recorded `versions[<v>].path` is a GUESS about the past: it was written
|
|
* by whichever WeKan stored the file, and the layout has changed - `<id>`,
|
|
* `<id>.<ext>`, `<id>-<version>-<name>` - while a migration, a storage move or
|
|
* an extension correction could rewrite one of the two and not the other.
|
|
*
|
|
* #6589 is what that costs when only the reader knows: a .drawio attachment
|
|
* was served fine (this search found it) and could not be RENAMED, because
|
|
* rename used the recorded path alone:
|
|
*
|
|
* Error: ENOENT: no such file or directory, rename
|
|
* '/data/files/attachments/6a7d66369c6aee799e857d36.drawio' -> ...
|
|
*
|
|
* So the search is a method now, and reading, renaming and deleting all use it.
|
|
* @return array of absolute candidate paths
|
|
*/
|
|
candidatePaths() {
|
|
const v = this.version();
|
|
const originalPath = v.path || '';
|
|
const normalized = (originalPath || '').replace(/\\/g, '/');
|
|
const isAvatar = normalized.includes('/avatars/') || (this.fileObj.collectionName === 'avatars');
|
|
const baseDir = isAvatar ? 'avatars' : 'attachments';
|
|
const writableBase = process.env.WRITABLE_PATH || process.cwd();
|
|
const endsWithFiles = isFilesRoot(writableBase);
|
|
const storageRoot = endsWithFiles
|
|
? path.join(writableBase, baseDir)
|
|
: path.join(writableBase, 'files', baseDir);
|
|
const resolvedStorageRoot = tryRealPath(storageRoot) || path.resolve(storageRoot);
|
|
|
|
// Build candidate list in priority order
|
|
const candidates = [];
|
|
|
|
// 0) Try to find project root and resolve from there
|
|
let projectRoot = null;
|
|
if (originalPath) {
|
|
// Find project root by looking for .meteor directory
|
|
let current = process.cwd();
|
|
let maxLevels = 10; // Safety limit
|
|
|
|
while (maxLevels-- > 0) {
|
|
const meteorPath = path.join(current, '.meteor');
|
|
const packagePath = path.join(current, 'package.json');
|
|
|
|
if (fs.existsSync(meteorPath) || fs.existsSync(packagePath)) {
|
|
projectRoot = current;
|
|
break;
|
|
}
|
|
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break; // Reached filesystem root
|
|
current = parent;
|
|
}
|
|
|
|
if (projectRoot) {
|
|
// Try resolving originalPath from project root
|
|
const fromProjectRoot = path.resolve(projectRoot, originalPath);
|
|
candidates.push(fromProjectRoot);
|
|
|
|
// Also try direct path: projectRoot/attachments/filename
|
|
const baseName = path.basename(normalized || this.fileObj._id || '');
|
|
if (baseName) {
|
|
const directPath = path.join(projectRoot, baseDir, baseName);
|
|
candidates.push(directPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1) Original as-is (absolute or relative resolved to CWD)
|
|
if (originalPath) {
|
|
candidates.push(originalPath);
|
|
if (!path.isAbsolute(originalPath)) {
|
|
candidates.push(path.resolve(process.cwd(), originalPath));
|
|
candidates.push(path.resolve(storageRoot, originalPath));
|
|
}
|
|
}
|
|
// 2) Same basename in storageRoot
|
|
const baseName = path.basename(normalized || this.fileObj._id || '');
|
|
if (baseName) {
|
|
candidates.push(path.join(storageRoot, baseName));
|
|
}
|
|
// 3) Only ObjectID (no extension) in storageRoot
|
|
if (this.fileObj && this.fileObj._id) {
|
|
candidates.push(path.join(storageRoot, String(this.fileObj._id)));
|
|
}
|
|
// 3) Old naming: {id}-{version}-{originalName}
|
|
if (this.fileObj.name) {
|
|
const safeName = sanitizeFilename(this.fileObj.name);
|
|
candidates.push(path.join(storageRoot, `${this.fileObj._id}-${this.versionName}-${safeName}`));
|
|
}
|
|
|
|
// 4) Fallback by prefix: {id}-{version}-* (covers unknown/changed original names)
|
|
if (this.fileObj && this.fileObj._id) {
|
|
try {
|
|
const prefix = `${this.fileObj._id}-${this.versionName}-`;
|
|
const dirEntries = fs.readdirSync(storageRoot);
|
|
const prefixedMatch = dirEntries.find((entry) => entry.startsWith(prefix));
|
|
if (prefixedMatch) {
|
|
candidates.push(path.join(storageRoot, prefixedMatch));
|
|
}
|
|
} catch (err) {
|
|
// Ignore listing errors and continue with other candidates
|
|
}
|
|
}
|
|
|
|
return { candidates, resolvedStorageRoot, storageRoot };
|
|
}
|
|
|
|
/** the path the file is ACTUALLY at, or undefined when none of them exists
|
|
* @return absolute path or undefined
|
|
*/
|
|
resolveExistingPath() {
|
|
const { candidates, resolvedStorageRoot, storageRoot } = this.candidatePaths();
|
|
for (const c of candidates) {
|
|
if (isSafeReadableFile(c, resolvedStorageRoot, storageRoot)) return c;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
getReadStream(range) {
|
|
const chosen = this.resolveExistingPath();
|
|
if (!chosen) {
|
|
return undefined;
|
|
}
|
|
// #6745: a byte range (inclusive end, like fs) streams only those bytes.
|
|
return range
|
|
? fs.createReadStream(chosen, { start: range.start, end: range.end })
|
|
: fs.createReadStream(chosen);
|
|
}
|
|
|
|
/** returns a write stream
|
|
* @param filePath if set, use this path
|
|
* @return the write stream
|
|
*/
|
|
getWriteStream(filePath) {
|
|
if (typeof filePath !== 'string') {
|
|
filePath = this.version().path;
|
|
}
|
|
const ret = fs.createWriteStream(filePath);
|
|
return ret;
|
|
}
|
|
|
|
/** writing finished
|
|
* @param finishedData the data of the write stream finish event
|
|
*/
|
|
writeStreamFinished(finishedData) {
|
|
}
|
|
|
|
/** remove the file */
|
|
unlink() {
|
|
// The file that is THERE, not the one the database remembers - see
|
|
// candidatePaths(). A delete that misses leaves the bytes on disk forever.
|
|
const filePath = this.resolveExistingPath()
|
|
|| this.version().path;
|
|
if (filePath) fs.unlink(filePath, () => {});
|
|
}
|
|
|
|
/** rename the file (physical)
|
|
* @li at database the filename is updated after this method
|
|
* @param newFilePath the new file path
|
|
*/
|
|
rename(newFilePath) {
|
|
const current = this.resolveExistingPath();
|
|
if (!current) {
|
|
// #6589: this used to be `fs.renameSync(<recorded path>, ...)` and the
|
|
// recorded path did not exist, so every rename of that attachment threw
|
|
// ENOENT and it stayed stuck under the wrong name for good. Say what is
|
|
// actually wrong - the file is gone, not the rename - and name the
|
|
// attachment, because the ENOENT named a path nobody recognised.
|
|
const recorded = this.version().path || '(none recorded)';
|
|
throw new Error(
|
|
`Attachment ${this.fileObj._id} (${this.fileObj.name || 'unnamed'}), version `
|
|
+ `${this.versionName}: no file found on disk. The database says ${recorded}, `
|
|
+ `and neither that nor any of the older layouts is there, so there is nothing to rename.`);
|
|
}
|
|
if (path.resolve(current) === path.resolve(newFilePath)) return;
|
|
fs.renameSync(current, newFilePath);
|
|
}
|
|
|
|
/** return the storage name
|
|
* @return the storage name
|
|
*/
|
|
getStorageName() {
|
|
return STORAGE_NAME_FILESYSTEM;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Strategy to store attachments on a cloud backend (S3-compatible, Azure Blob,
|
|
* Google Cloud Storage) through @tweedegolf/storage-abstraction.
|
|
*
|
|
* The abstraction is asynchronous and result-object based, while the rest of
|
|
* WeKan expects synchronous, pipe-able streams. We bridge the two with
|
|
* PassThrough streams:
|
|
* - getReadStream() returns a PassThrough immediately and fills it once the
|
|
* async getFileAsStream() resolves.
|
|
* - getWriteStream() returns a PassThrough that the async addFileFromStream()
|
|
* consumes; waitUntilStored() exposes the upload's completion promise so
|
|
* moveToStorage() can confirm the upload before deleting the source file.
|
|
*
|
|
* The object key is `${id}-${version}-${name}` and is stored in
|
|
* `versions.<version>.path` (mirrors the filesystem strategy) so reads can find
|
|
* the object again regardless of provider.
|
|
*/
|
|
// #6745: where a file bound for cloud storage is spooled before its upload
|
|
// (FileStoreStrategyCloud.getWriteStream). A file there lives only for the
|
|
// length of one upload.
|
|
function cloudSpoolDir() {
|
|
const dir = path.join(os.tmpdir(), 'wekan-cloud-upload');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
// S3 needs the body length up front to stream a body; the other providers
|
|
// stream a file without it. S3 also gets the server-side encryption the
|
|
// administrator chose (models/lib/s3Encryption.js), on every write.
|
|
function cloudUploadOptions(provider, size, adapter) {
|
|
if (provider !== STORAGE_NAME_S3) return {};
|
|
const options = { ...((adapter && adapter.uploadOptions) || {}) };
|
|
if (size !== undefined) options.ContentLength = size;
|
|
return options;
|
|
}
|
|
|
|
export class FileStoreStrategyCloud extends FileStoreStrategy {
|
|
|
|
/** constructor
|
|
* @param provider cloud storage name ('s3' | 'azure' | 'gcs')
|
|
* @param fileObj the current file object
|
|
* @param versionName the current version
|
|
*/
|
|
constructor(provider, fileObj, versionName, collection) {
|
|
super(fileObj, versionName, collection);
|
|
this.provider = provider;
|
|
this._uploadPromise = null;
|
|
this._key = null;
|
|
}
|
|
|
|
/** download the file
|
|
* @param http the current http request
|
|
* @param cacheControl cacheControl of FilesCollection
|
|
*/
|
|
/*
|
|
* FAIL CLOSED when the strategy will not serve the file.
|
|
*
|
|
* Returning false here means "not handled", and Meteor-Files then serves the
|
|
* file ITSELF - from the stored path, with the stored Content-Type, and none
|
|
* of the policy httpStreamOutput applies. A stored attachment of type
|
|
* text/html came back as text/html, status 200, inline: precisely the stored
|
|
* XSS that policy exists to stop.
|
|
*
|
|
* What makes that a bypass rather than a fallback is WHY the strategy
|
|
* declines. getReadStream() returns nothing when the file cannot be resolved
|
|
* INSIDE the storage root - the containment check of GHSA-4mxf-m8pq-xc9p - so
|
|
* "false" means "this is not a file WeKan may serve", and handing it to a
|
|
* server with no containment check answers the refusal with the file.
|
|
*
|
|
* A file that is genuinely absent gets the same 404 it would have got anyway.
|
|
*/
|
|
interceptDownload(http, cacheControl) {
|
|
const readStream = this.getReadStream();
|
|
const downloadFlag = http?.params?.query?.download;
|
|
|
|
if (!readStream) {
|
|
http.response.statusCode = 404;
|
|
http.response.end('not found');
|
|
return true;
|
|
}
|
|
|
|
httpStreamOutput(readStream, this.fileObj.name, http, downloadFlag, cacheControl, this.fileObj);
|
|
return true;
|
|
}
|
|
|
|
/** after file remove */
|
|
onAfterRemove() {
|
|
this.unlink();
|
|
super.onAfterRemove();
|
|
}
|
|
|
|
/** returns a read stream (filled asynchronously from the cloud backend)
|
|
* @return the read stream
|
|
*/
|
|
getReadStream(range) {
|
|
const pass = new PassThrough();
|
|
const adapter = getCloudAdapter(this.provider);
|
|
if (!adapter) {
|
|
process.nextTick(() => pass.destroy(new Error(`Cloud storage "${this.provider}" is not configured`)));
|
|
return pass;
|
|
}
|
|
const key = this.getObjectKey();
|
|
// #6745: a byte range (inclusive end) is fetched from the backend as such.
|
|
const rangeArgs = range ? [{ start: range.start, end: range.end }] : [];
|
|
adapter.storage.getFileAsStream(adapter.bucketName, key, ...rangeArgs)
|
|
.then(result => {
|
|
if (!result || result.error || !result.value) {
|
|
pass.destroy(new Error(result && result.error ? result.error : 'No cloud read stream'));
|
|
return;
|
|
}
|
|
result.value.on('error', error => pass.destroy(error));
|
|
result.value.pipe(pass);
|
|
})
|
|
.catch(error => pass.destroy(error));
|
|
return pass;
|
|
}
|
|
|
|
/** returns a write stream that uploads to the cloud backend
|
|
* @param filePath the object key to write to (from getNewPath)
|
|
* @return the write stream
|
|
*/
|
|
getWriteStream(filePath) {
|
|
const pass = new PassThrough();
|
|
this._key = (typeof filePath === 'string' && filePath) ? filePath : this.getObjectKey();
|
|
this._uploadError = null;
|
|
|
|
const adapter = getCloudAdapter(this.provider);
|
|
if (!adapter) {
|
|
this._uploadError = new Error(`Cloud storage "${this.provider}" is not configured`);
|
|
this._uploadPromise = Promise.resolve();
|
|
process.nextTick(() => pass.destroy(this._uploadError));
|
|
return pass;
|
|
}
|
|
|
|
// #6745: spool the incoming bytes to a temporary FILE, then upload that
|
|
// file as a stream of known length. Streaming a live body of unknown length
|
|
// to S3 fails with
|
|
// Invalid value "undefined" for header "x-amz-decoded-content-length"
|
|
// and, if the socket drops mid-upload, the AWS SDK's body-stream promise
|
|
// rejects UNHANDLED and crashes the server (SyncedCron treats it as fatal).
|
|
// This used to be avoided by collecting the whole file in one Buffer, which
|
|
// held every byte of a file moved to the cloud in server memory (no cap:
|
|
// the upload limit defaults to unlimited) - a 2 GB video needed 2 GB of
|
|
// RAM. A file on disk has a known length (ContentLength for S3) and is
|
|
// re-readable, so neither problem returns and memory stays at a stream
|
|
// buffer. The upload promise NEVER rejects - any failure is captured in
|
|
// this._uploadError (read by waitUntilStored()).
|
|
const spoolPath = path.join(cloudSpoolDir(), `${Random.id()}-cloud-upload`);
|
|
const spool = fs.createWriteStream(spoolPath);
|
|
const removeSpool = () => fs.promises.unlink(spoolPath).catch(() => {});
|
|
this._uploadPromise = new Promise(resolve => {
|
|
const fail = error => {
|
|
if (!this._uploadError) {
|
|
this._uploadError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
try { spool.destroy(); } catch (e) { /* already closed */ }
|
|
// removeSpool() catches its own error, so it never rejects.
|
|
void removeSpool().then(resolve);
|
|
};
|
|
pass.on('error', fail);
|
|
spool.on('error', fail);
|
|
spool.on('finish', () => {
|
|
if (this._uploadError) return;
|
|
// Every failure is caught below and the tail never rejects.
|
|
void fs.promises.stat(spoolPath)
|
|
.then(stat => adapter.storage.addFileFromPath({
|
|
origPath: spoolPath,
|
|
bucketName: adapter.bucketName,
|
|
targetPath: this._key,
|
|
options: cloudUploadOptions(this.provider, stat.size, adapter),
|
|
}))
|
|
.then(result => {
|
|
if (result && result.error) {
|
|
this._uploadError = new Error(result.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
this._uploadError = error instanceof Error ? error : new Error(String(error));
|
|
})
|
|
.then(removeSpool)
|
|
.then(resolve);
|
|
});
|
|
pass.pipe(spool);
|
|
});
|
|
return pass;
|
|
}
|
|
|
|
/** Promise resolving once the upload has durably completed on the backend.
|
|
* moveToStorage() awaits this before deleting the source file.
|
|
*/
|
|
waitUntilStored() {
|
|
// Wait for the (non-rejecting) upload promise, then re-throw any captured
|
|
// error so moveToStorage's try/catch keeps the source file intact instead of
|
|
// deleting it after a failed upload.
|
|
return Promise.resolve(this._uploadPromise).then(() => {
|
|
if (this._uploadError) {
|
|
throw this._uploadError;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** writing finished — persist the object key in the version meta */
|
|
writeStreamFinished(finishedData) {
|
|
const field = this.getCloudFileIdName();
|
|
this.collection.updateAsync(
|
|
{ _id: this.fileObj._id },
|
|
{ $set: { [field]: this._key } },
|
|
).catch(error => {
|
|
console.error('Failed to persist cloud file id:', error);
|
|
});
|
|
}
|
|
|
|
/** remove the file from the cloud backend */
|
|
unlink() {
|
|
const adapter = getCloudAdapter(this.provider);
|
|
if (!adapter) {
|
|
return;
|
|
}
|
|
const key = this.getObjectKey();
|
|
Promise.resolve(adapter.storage.removeFile(adapter.bucketName, key)).catch(error => {
|
|
console.error('Failed to remove cloud file:', error);
|
|
});
|
|
|
|
const field = this.getCloudFileIdName();
|
|
this.collection.updateAsync(
|
|
{ _id: this.fileObj._id },
|
|
{ $unset: { [field]: 1 } },
|
|
).catch(error => {
|
|
console.error('Failed to clear cloud file id:', error);
|
|
});
|
|
}
|
|
|
|
/** the object key used in the cloud bucket */
|
|
getObjectKey() {
|
|
const version = this.version();
|
|
if (version.path) {
|
|
return version.path;
|
|
}
|
|
const meta = version.meta || {};
|
|
if (meta[`${this.provider}FileId`]) {
|
|
return meta[`${this.provider}FileId`];
|
|
}
|
|
const safeName = sanitizeFilename(this.fileObj.name);
|
|
return `${this.fileObj._id}-${this.versionName}-${safeName}`;
|
|
}
|
|
|
|
/** the object key for a fresh write (no leading storage directory) */
|
|
getNewPath(storagePath, name) {
|
|
if (typeof name !== 'string') {
|
|
name = this.fileObj.name;
|
|
}
|
|
const safeName = sanitizeFilename(name);
|
|
return `${this.fileObj._id}-${this.versionName}-${safeName}`;
|
|
}
|
|
|
|
/** the database field storing this provider's object key */
|
|
getCloudFileIdName() {
|
|
return `versions.${this.versionName}.meta.${this.provider}FileId`;
|
|
}
|
|
|
|
/** rename the file: cloud objects are keyed by id+version+name, so a rename
|
|
* is a server-side copy to the new key followed by removal of the old one.
|
|
*/
|
|
rename(newFilePath) {
|
|
const adapter = getCloudAdapter(this.provider);
|
|
if (!adapter) {
|
|
return;
|
|
}
|
|
const oldKey = this.getObjectKey();
|
|
const newKey = newFilePath;
|
|
if (!oldKey || !newKey || oldKey === newKey) {
|
|
return;
|
|
}
|
|
adapter.storage.getFileAsStream(adapter.bucketName, oldKey)
|
|
.then(result => {
|
|
if (!result || result.error || !result.value) {
|
|
throw new Error(result && result.error ? result.error : 'No cloud read stream for rename');
|
|
}
|
|
return adapter.storage.addFileFromStream({
|
|
stream: result.value,
|
|
bucketName: adapter.bucketName,
|
|
targetPath: newKey,
|
|
// A renamed object is a new object: it is encrypted like the old one.
|
|
options: cloudUploadOptions(this.provider, undefined, adapter),
|
|
});
|
|
})
|
|
.then(() => adapter.storage.removeFile(adapter.bucketName, oldKey))
|
|
.catch(error => console.error('Failed to rename cloud file:', error));
|
|
}
|
|
|
|
/** return the storage name (the provider) */
|
|
getStorageName() {
|
|
return this.provider;
|
|
}
|
|
}
|
|
|
|
|
|
/** move the fileObj to another storage
|
|
* @param fileObj move this fileObj to another storage
|
|
* @param storageDestination the storage destination (fs or gridfs)
|
|
* @param fileStoreStrategyFactory get FileStoreStrategy from this factory
|
|
* @return a Promise that resolves once every version has finished moving.
|
|
* Existing callers may ignore the return value (fire-and-forget); the
|
|
* server-side bulk move job awaits it to move attachments sequentially.
|
|
*/
|
|
export const moveToStorage = async function(fileObj, storageDestination, fileStoreStrategyFactory) {
|
|
const collection = fileStoreStrategyFactory.collection || Attachments;
|
|
|
|
// When migrating/moving a file, sanitize known exploits from its CONTENT with the
|
|
// same general function used on upload (strip JavaScript + XML-loop DOCTYPE/ENTITY
|
|
// from SVG), so the sanitized bytes are what get written to the destination. Runs
|
|
// in place on the local filesystem source (the common migration direction); the
|
|
// serve-time sanitizer is the backstop for files still on a remote backend.
|
|
try {
|
|
const { sanitizeUploadedFileExploits } = require('./sanitizeUploadedFile');
|
|
if (await sanitizeUploadedFileExploits(fileObj)) {
|
|
await collection.updateAsync({ _id: fileObj._id }, { $set: { versions: fileObj.versions } })
|
|
.catch(error => console.error('Failed to persist sanitized versions before move:', error));
|
|
try {
|
|
await require('/server/lib/filenameSanitizeLog').logContentSanitized({
|
|
fileObj, source: 'storageMigration',
|
|
});
|
|
} catch (e) { /* best effort */ }
|
|
}
|
|
} catch (error) {
|
|
console.error('[moveToStorage] content exploit sanitize failed (continuing):', error);
|
|
}
|
|
|
|
// When migrating/moving a file to its destination storage, fix the filename with
|
|
// the SAME general function used on upload: detect the real file type and correct
|
|
// the extension, strip invisible/exploit characters, fold confusable homoglyphs,
|
|
// cap the length to a portable maximum, keep it path-safe, and disambiguate a
|
|
// different-content same-name collision with increasing numbering. The corrected
|
|
// name is saved as the filename at the destination.
|
|
try {
|
|
const { finalizeStoredFileName } = require('./fileTypeCorrection');
|
|
const originalName = fileObj.name;
|
|
const { name: finalName, changed, detectedMime } = await finalizeStoredFileName(collection, fileObj, fileStoreStrategyFactory);
|
|
if (changed && finalName) {
|
|
const lastDot = finalName.lastIndexOf('.');
|
|
const extension = lastDot === -1 ? '' : finalName.substring(lastDot + 1).toLowerCase();
|
|
await collection.updateAsync({ _id: fileObj._id }, { $set: {
|
|
name: finalName,
|
|
extension,
|
|
extensionWithDot: extension ? `.${extension}` : '',
|
|
} }).catch(error => console.error('Failed to persist migrated attachment name:', error));
|
|
// Log to Admin Panel / Problems: who uploaded, why it was sanitized, when.
|
|
try {
|
|
const { sanitizationReasons } = require('./uploadFileName');
|
|
await require('/server/lib/filenameSanitizeLog').logFilenameSanitized({
|
|
fileObj, source: 'storageMigration',
|
|
reasons: sanitizationReasons(originalName, detectedMime || fileObj.type, finalName),
|
|
from: originalName, to: finalName,
|
|
});
|
|
} catch (e) { /* best effort */ }
|
|
fileObj.name = finalName;
|
|
}
|
|
} catch (error) {
|
|
// Never block a move because name-finalization failed; fall back to the basic
|
|
// path-traversal sanitizer so a malicious stored name is still cleaned up.
|
|
console.error('[moveToStorage] filename finalization failed, using basic sanitizer:', error);
|
|
const safeName = sanitizeFilename(fileObj.name);
|
|
if (safeName !== fileObj.name) {
|
|
collection.updateAsync({ _id: fileObj._id }, { $set: { name: safeName } }).catch(e => {
|
|
console.error('Failed to persist sanitized attachment name:', e);
|
|
});
|
|
fileObj.name = safeName;
|
|
}
|
|
}
|
|
|
|
const versionPromises = Object.keys(fileObj.versions).map(versionName => new Promise(resolve => {
|
|
const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
|
|
const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, storageDestination);
|
|
|
|
// Compare by storage name rather than class name: every cloud provider
|
|
// shares the same strategy class, so constructor.name would wrongly treat
|
|
// an S3 -> Azure move as "already there".
|
|
if (strategyRead.getStorageName() == strategyWrite.getStorageName()) {
|
|
// Already on the destination storage, nothing to move for this version.
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const readStream = strategyRead.getReadStream();
|
|
|
|
const filePath = strategyWrite.getNewPath(fileStoreStrategyFactory.storagePath);
|
|
|
|
// Before writing to local-disk (filesystem) storage, make sure there is enough
|
|
// free space for this version — so a large move cannot fill the disk. When the
|
|
// platform does not expose free-space info (e.g. a sandbox), hasEnoughDiskSpace
|
|
// returns true and we proceed with chunked streaming, relying on the write-error
|
|
// handler below to stop and remove any partial output. Never delete the source.
|
|
if (strategyWrite.getStorageName() === STORAGE_NAME_FILESYSTEM) {
|
|
const versionSize = ((fileObj.versions || {})[versionName] || {}).size || fileObj.size || 0;
|
|
if (!hasEnoughDiskSpace(fileStoreStrategyFactory.storagePath, versionSize)) {
|
|
console.error(
|
|
'[moveToStorage] not enough free disk space to move attachment',
|
|
fileObj._id, `version "${versionName}" (${versionSize} bytes) — skipping, source left intact.`,
|
|
);
|
|
resolve();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const writeStream = strategyWrite.getWriteStream(filePath);
|
|
|
|
// The source binary may be missing at its recorded location (e.g. a file left
|
|
// half-moved by an earlier interrupted run: storage says "gridfs" but the
|
|
// GridFS id reference is gone, or a filesystem path that no longer exists). In
|
|
// that case getReadStream()/getWriteStream() return undefined. Skip this
|
|
// version cleanly instead of throwing "Cannot read properties of undefined
|
|
// (reading 'on')", and crucially do NOT delete the source — leave it intact so
|
|
// no data is lost and the admin can investigate.
|
|
if (!readStream || !writeStream) {
|
|
console.error(
|
|
'[moveToStorage] cannot move attachment',
|
|
fileObj._id,
|
|
`version "${versionName}" from ${strategyRead.getStorageName()} to ${strategyWrite.getStorageName()}:`,
|
|
!readStream
|
|
? 'source file not found at its recorded location'
|
|
: 'could not open the destination write stream',
|
|
'— skipping this file, source left intact.',
|
|
);
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let ended = false;
|
|
let finished = false;
|
|
let settled = false;
|
|
|
|
// Persist the new storage location and delete the source — but only once
|
|
// the write is durably stored. For cloud backends waitUntilStored() awaits
|
|
// the actual upload, so a failed upload never deletes the source file.
|
|
const finalize = async () => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
try {
|
|
if (typeof strategyWrite.waitUntilStored === 'function') {
|
|
await strategyWrite.waitUntilStored();
|
|
}
|
|
await (fileStoreStrategyFactory.collection || Attachments).updateAsync({ _id: fileObj._id }, { $set: {
|
|
[`versions.${versionName}.storage`]: strategyWrite.getStorageName(),
|
|
[`versions.${versionName}.path`]: filePath,
|
|
} });
|
|
strategyRead.unlink();
|
|
} catch (error) {
|
|
console.error('[moveToStorage] write not confirmed, keeping source intact: ', error, fileObj._id);
|
|
}
|
|
resolve();
|
|
};
|
|
|
|
// https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
|
|
const settle = () => {
|
|
if (ended && finished) {
|
|
// finalize() handles its own errors, but guard the floating promise so a
|
|
// stray rejection can never become an unhandled rejection (fatal to
|
|
// SyncedCron).
|
|
finalize().catch(error => {
|
|
console.error('[moveToStorage] finalize failed: ', error, fileObj._id);
|
|
if (!settled) {
|
|
settled = true;
|
|
}
|
|
resolve();
|
|
});
|
|
}
|
|
};
|
|
|
|
const fail = (error, label) => {
|
|
console.error(`[${label}]: `, error, fileObj._id);
|
|
// Stop streaming immediately and remove the partial output we already wrote,
|
|
// so a failed move never leaves a half-written destination file behind. Only
|
|
// the destination is removed (filesystem); the SOURCE is always left intact.
|
|
try { readStream.destroy(); } catch (e) { /* ignore */ }
|
|
try { writeStream.destroy(); } catch (e) { /* ignore */ }
|
|
if (strategyWrite.getStorageName() === STORAGE_NAME_FILESYSTEM) {
|
|
fs.promises.unlink(filePath).catch(() => {}); // remove partial destination
|
|
}
|
|
if (!settled) {
|
|
settled = true;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
writeStream.on('error', error => fail(error, 'writeStream error'));
|
|
readStream.on('error', error => fail(error, 'readStream error'));
|
|
|
|
writeStream.on('finish', finishedData => {
|
|
try {
|
|
strategyWrite.writeStreamFinished(finishedData);
|
|
} catch (error) {
|
|
// Persisting the storage id must never crash the process via this
|
|
// (synchronous) event callback; the move is finalized below regardless.
|
|
console.error('[writeStreamFinished error]: ', error, fileObj._id);
|
|
}
|
|
finished = true;
|
|
settle();
|
|
});
|
|
|
|
readStream.on('end', () => {
|
|
ended = true;
|
|
settle();
|
|
});
|
|
|
|
readStream.pipe(writeStream);
|
|
}));
|
|
|
|
return Promise.all(versionPromises);
|
|
};
|
|
|
|
// Add an attachment FROM A STREAM: the bytes go to a temp file as they arrive
|
|
// and the collection takes that file, so nothing is ever held whole in memory.
|
|
//
|
|
// This is what `copyFile` below does per version, lifted out so the .zip import
|
|
// can do it too (#1173): a 500 MB attachment inside an archive is piped from the
|
|
// archive to disk and handed over, never buffered. `Attachments.addFile` is the
|
|
// Meteor-Files call that takes a path rather than a Buffer - `write()` takes a
|
|
// Buffer, which is the thing to avoid here.
|
|
export const addAttachmentFromStream = function(
|
|
readStream, { fileName, type, meta, userId, size }, factory,
|
|
) {
|
|
return new Promise((resolve, reject) => {
|
|
const collection = (factory && factory.collection) || Attachments;
|
|
const storagePath = (factory && factory.storagePath) || '/tmp';
|
|
const safeName = sanitizeFilename(fileName || 'attachment');
|
|
const tempPath = path.join(storagePath, `${Random.id()}-import-${safeName}`);
|
|
const writeStream = fs.createWriteStream(tempPath);
|
|
|
|
const fail = error => {
|
|
try { readStream.destroy(); } catch (e) { /* already gone */ }
|
|
try { writeStream.destroy(); } catch (e) { /* already gone */ }
|
|
fs.promises.unlink(tempPath).catch(() => {});
|
|
reject(error);
|
|
};
|
|
|
|
readStream.on('error', fail);
|
|
writeStream.on('error', fail);
|
|
// Meteor-Files 3's addFile is async - addFile(path, opts, proceedAfterUpload)
|
|
// returning the file - and takes no callback. A callback passed as its third
|
|
// argument was never called, so every streamed attachment (a .zip import,
|
|
// a clone, the REST raw upload, a storage move) waited forever and the
|
|
// import stopped at the first file. copyFile below awaits it the same way.
|
|
writeStream.on('finish', () => {
|
|
Promise.resolve(collection.addFile(
|
|
tempPath,
|
|
{
|
|
fileName: fileName || 'attachment',
|
|
type: type || 'application/octet-stream',
|
|
meta,
|
|
userId,
|
|
size,
|
|
fileId: new ObjectId().toString(),
|
|
},
|
|
true,
|
|
)).then(resolve, fail);
|
|
});
|
|
|
|
readStream.pipe(writeStream);
|
|
});
|
|
};
|
|
|
|
// `fileIdFor(versionName)`, when given, names the copy: a durable rule copy
|
|
// (server/lib/syncRuleCopyCardCommand.js) derives it so a replay can find the
|
|
// copy it already made. Without it every copy gets a fresh id, as before.
|
|
export const copyFile = async function(fileObj, newCardId, fileStoreStrategyFactory, { fileIdFor = null } = {}) {
|
|
const newCard = await ReactiveCache.getCard(newCardId);
|
|
|
|
// Sanitize known exploits from the source content, and fix + sanitize the copied
|
|
// filename with the same general upload function (fold homoglyphs, strip
|
|
// invisible/exploit chars, correct the extension for the type, cap the length).
|
|
try {
|
|
const { sanitizeUploadedFileExploits } = require('./sanitizeUploadedFile');
|
|
await sanitizeUploadedFileExploits(fileObj);
|
|
} catch (e) { /* best effort */ }
|
|
let copyName = fileObj.name;
|
|
try {
|
|
const { sanitizeUploadFileName } = require('./uploadFileName');
|
|
copyName = sanitizeUploadFileName(fileObj.name, fileObj.type) || fileObj.name;
|
|
} catch (e) { /* keep original name on failure */ }
|
|
|
|
return Promise.all(Object.keys(fileObj.versions).map(versionName => new Promise((resolve, reject) => {
|
|
const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
|
|
const readStream = strategyRead.getReadStream();
|
|
const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, STORAGE_NAME_FILESYSTEM);
|
|
|
|
const safeName = sanitizeFilename(fileObj.name);
|
|
const tempPath = path.join(fileStoreStrategyFactory.storagePath, Random.id() + "-" + versionName + "-" + safeName);
|
|
const writeStream = strategyWrite.getWriteStream(tempPath);
|
|
|
|
// Source binary missing at its recorded location — skip this version instead
|
|
// of throwing "Cannot read properties of undefined (reading 'on')".
|
|
if (!readStream || !writeStream) {
|
|
console.error(
|
|
'[copyFile] cannot copy attachment',
|
|
fileObj._id,
|
|
`version "${versionName}": source file not found at its recorded location — skipping.`,
|
|
);
|
|
if (readStream) readStream.destroy();
|
|
if (writeStream) writeStream.destroy();
|
|
fs.promises.unlink(tempPath).catch(() => {});
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
// On any error, stop streaming and remove the partial temp file so a failed
|
|
// copy never leaves a half-written file behind (the source is untouched).
|
|
const cleanupPartial = (error, label) => {
|
|
console.error(`[${label}]: `, error, fileObj._id);
|
|
try { readStream.destroy(); } catch (e) { /* ignore */ }
|
|
try { writeStream.destroy(); } catch (e) { /* ignore */ }
|
|
fs.promises.unlink(tempPath).catch(() => {});
|
|
reject(error);
|
|
};
|
|
|
|
writeStream.on('error', error => cleanupPartial(error, 'writeStream error'));
|
|
readStream.on('error', error => cleanupPartial(error, 'readStream error'));
|
|
|
|
// The destination must be fully flushed before addFile reads it.
|
|
writeStream.on('finish', async () => {
|
|
try {
|
|
const collection = fileStoreStrategyFactory.collection || Attachments;
|
|
const fileRef = await collection.addFile(
|
|
tempPath,
|
|
{
|
|
fileName: copyName,
|
|
type: fileObj.type,
|
|
meta: {
|
|
boardId: newCard.boardId,
|
|
cardId: newCardId,
|
|
listId: newCard.listId,
|
|
swimlaneId: newCard.swimlaneId,
|
|
source: 'copy',
|
|
copyFrom: fileObj._id,
|
|
copyStorage: strategyRead.getStorageName(),
|
|
},
|
|
userId: fileObj.userId,
|
|
size: fileObj.size,
|
|
fileId: (fileIdFor && fileIdFor(versionName)) || new ObjectId().toString(),
|
|
},
|
|
true,
|
|
);
|
|
await collection.updateAsync({ _id: fileRef._id }, { $set: { userId: fileObj.userId } });
|
|
resolve(fileRef._id);
|
|
} catch (error) { cleanupPartial(error, 'copyFile addFile error'); }
|
|
});
|
|
|
|
readStream.pipe(writeStream);
|
|
})));
|
|
};
|
|
|
|
export const rename = function(fileObj, newName, fileStoreStrategyFactory) {
|
|
// Sanitize the new name to prevent path traversal
|
|
const safeName = sanitizeFilename(newName);
|
|
|
|
const lastDot = safeName.lastIndexOf('.');
|
|
const extension = lastDot === -1 ? '' : safeName.substring(lastDot + 1).toLowerCase();
|
|
const extensionWithDot = extension ? `.${extension}` : '';
|
|
|
|
Object.keys(fileObj.versions).forEach(versionName => {
|
|
const strategy = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
|
|
const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, safeName);
|
|
strategy.rename(newFilePath);
|
|
|
|
(fileStoreStrategyFactory.collection || Attachments).updateAsync({ _id: fileObj._id }, { $set: {
|
|
"name": safeName,
|
|
"extension": extension,
|
|
"extensionWithDot": extensionWithDot,
|
|
[`versions.${versionName}.path`]: newFilePath,
|
|
} }).catch(error => {
|
|
console.error('Failed to persist renamed attachment path:', error);
|
|
});
|
|
});
|
|
};
|