mirror of https://github.com/wekan/wekan
The Open Source kanban (built with Meteor). Keep variable/table/field names camelCase. For translations, only add Pull Request changes to wekan/i18n/en.i18n.json , other translations are done at https://transifex.com/wekan/wekan only.
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
348 lines
13 KiB
348 lines
13 KiB
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { MongoInternals } from 'meteor/mongo';
|
|
import { check } from 'meteor/check';
|
|
import { isFileValid } from './fileValidation';
|
|
import { createBucket } from './lib/grid/createBucket';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
|
|
import FileStoreStrategyFactory, { moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreStrategy';
|
|
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
|
import AttachmentStorageSettings from './attachmentStorageSettings';
|
|
import Attachments, { normalizeRemovedFiles } from './attachments';
|
|
import Boards from '/models/boards';
|
|
import { allowIsBoardMember } from '/server/lib/utils';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server-only configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let attachmentUploadExternalProgram;
|
|
let attachmentUploadMimeTypes = [];
|
|
let attachmentUploadSize = 0;
|
|
|
|
const attachmentBucket = createBucket('attachments');
|
|
|
|
// Compute storage path:
|
|
// - Docker (WRITABLE_PATH=/data): /data/files/attachments
|
|
// - Snap (WRITABLE_PATH=$SNAP_COMMON/files): $SNAP_COMMON/files/attachments
|
|
const basePath = process.env.WRITABLE_PATH || process.cwd();
|
|
const endsWithFiles = basePath.endsWith('/files') || basePath.endsWith('\\files');
|
|
const storagePath = endsWithFiles
|
|
? path.join(basePath, 'attachments')
|
|
: path.join(basePath, 'files', 'attachments');
|
|
|
|
if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
|
|
attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
|
|
attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
|
|
}
|
|
|
|
if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
|
|
attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
|
|
|
|
if (isNaN(attachmentUploadSize)) {
|
|
attachmentUploadSize = 0;
|
|
}
|
|
}
|
|
|
|
if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
|
|
attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
|
|
|
|
if (!attachmentUploadExternalProgram.includes("{file}")) {
|
|
attachmentUploadExternalProgram = undefined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File store strategy factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(
|
|
AttachmentStoreStrategyFilesystem, storagePath,
|
|
AttachmentStoreStrategyGridFs, attachmentBucket,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assign server-only FilesCollection callbacks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Attachments.storagePath = function () {
|
|
return fileStoreStrategyFactory.storagePath;
|
|
};
|
|
|
|
Attachments.onAfterUpload = async function (fileObj) {
|
|
// Get default storage backend from settings
|
|
let defaultStorage = STORAGE_NAME_FILESYSTEM;
|
|
try {
|
|
const settings = await AttachmentStorageSettings.findOneAsync({});
|
|
if (settings) {
|
|
defaultStorage = settings.getDefaultStorage();
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not get attachment storage settings, using default:', error);
|
|
}
|
|
|
|
// Set initial storage to filesystem (temporary)
|
|
Object.keys(fileObj.versions).forEach(versionName => {
|
|
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
|
});
|
|
|
|
this._now = new Date();
|
|
await Attachments.updateAsync({ _id: fileObj._id }, { $set: { "versions": fileObj.versions, "uploadedAtOstrio": this._now } });
|
|
|
|
// Use selected storage backend or copy storage if specified
|
|
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
|
|
|
|
// Only migrate if the destination is different from filesystem
|
|
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
|
|
const fileObjId = fileObj._id;
|
|
// Note: Meteor.call('validateAttachmentAndMoveToStorage', ...) cannot be used here
|
|
// because server-side calls have this.userId=null, triggering not-authorized.
|
|
// Call the validation and migration logic directly instead.
|
|
Meteor.defer(async () => {
|
|
try {
|
|
const currentFileObj = await ReactiveCache.getAttachment(fileObjId);
|
|
if (!currentFileObj) return;
|
|
|
|
const isValid = await isFileValid(currentFileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
|
|
if (!isValid) {
|
|
await Attachments.removeAsync(fileObjId);
|
|
return;
|
|
}
|
|
|
|
const fileObjAfterValidation = await ReactiveCache.getAttachment(fileObjId);
|
|
if (fileObjAfterValidation) {
|
|
moveToStorage(fileObjAfterValidation, storageDestination, fileStoreStrategyFactory);
|
|
}
|
|
} catch (error) {
|
|
console.error('[onAfterUpload] Error during validation and storage migration:', error);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
Attachments.interceptDownload = function (http, fileObj, versionName) {
|
|
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
|
return ret;
|
|
};
|
|
|
|
Attachments.onAfterRemove = function (filesInput) {
|
|
const files = normalizeRemovedFiles(filesInput);
|
|
|
|
files.forEach(fileObj => {
|
|
if (!fileObj || !fileObj.versions) {
|
|
return;
|
|
}
|
|
|
|
Object.keys(fileObj.versions).forEach(versionName => {
|
|
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
|
|
});
|
|
});
|
|
};
|
|
|
|
// We authorize the attachment download either:
|
|
// - if the board is public, everyone (even unconnected) can download it
|
|
// - if the board is private, only board members can download it
|
|
// Note: ostrio:files v3.x uses `await this.protected.call(...)` in _checkAccess,
|
|
// so this function can be async and use findOneAsync for Meteor 3.x compatibility.
|
|
Attachments.protected = async function (fileObj) {
|
|
// file may have been deleted already again after upload validation failed
|
|
if (!fileObj) {
|
|
return false;
|
|
}
|
|
const board = await Boards.findOneAsync(fileObj.meta.boardId);
|
|
if (!board) {
|
|
return false;
|
|
}
|
|
if (board.isPublic()) {
|
|
return true;
|
|
}
|
|
return board.hasMember(this.userId);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backward compatibility methods (override client stubs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
|
|
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Meteor methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Meteor.methods({
|
|
// Validate image URL to prevent SVG-based DoS attacks
|
|
validateImageUrl(imageUrl) {
|
|
check(imageUrl, String);
|
|
|
|
if (!imageUrl) {
|
|
return { valid: false, reason: 'Empty URL' };
|
|
}
|
|
|
|
// Block SVG files and data URIs
|
|
if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
|
|
}
|
|
return { valid: false, reason: 'SVG images are blocked for security reasons' };
|
|
}
|
|
|
|
// Block data URIs that could contain malicious content
|
|
if (imageUrl.startsWith('data:')) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn('Blocked data URI image URL:', imageUrl);
|
|
}
|
|
return { valid: false, reason: 'Data URIs are blocked for security reasons' };
|
|
}
|
|
|
|
// Validate URL format
|
|
try {
|
|
const url = new URL(imageUrl);
|
|
// Only allow http and https protocols
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
|
|
}
|
|
} catch (e) {
|
|
return { valid: false, reason: 'Invalid URL format' };
|
|
}
|
|
|
|
return { valid: true };
|
|
},
|
|
async moveAttachmentToStorage(fileObjId, storageDestination) {
|
|
check(fileObjId, String);
|
|
check(storageDestination, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
|
}
|
|
|
|
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
|
if (!fileObj) {
|
|
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
|
}
|
|
|
|
const board = await ReactiveCache.getBoard(fileObj.meta?.boardId);
|
|
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
|
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
|
}
|
|
|
|
// Allowlist storage destinations
|
|
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
|
if (!allowedDestinations.includes(storageDestination)) {
|
|
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
|
}
|
|
|
|
moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
|
|
},
|
|
async renameAttachment(fileObjId, newName) {
|
|
check(fileObjId, String);
|
|
check(newName, String);
|
|
|
|
const currentUserId = this.userId;
|
|
if (!currentUserId) {
|
|
throw new Meteor.Error('not-authorized', 'User must be logged in');
|
|
}
|
|
|
|
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
|
if (!fileObj) {
|
|
throw new Meteor.Error('file-not-found', 'Attachment not found');
|
|
}
|
|
|
|
// Verify the user has permission to modify this attachment
|
|
const board = await ReactiveCache.getBoard(fileObj.meta?.boardId);
|
|
if (!board) {
|
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
|
}
|
|
|
|
if (!allowIsBoardMember(currentUserId, board)) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.meta?.boardId}`);
|
|
}
|
|
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
|
|
}
|
|
|
|
rename(fileObj, newName, fileStoreStrategyFactory);
|
|
},
|
|
async validateAttachment(fileObjId) {
|
|
check(fileObjId, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
|
}
|
|
|
|
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
|
if (!fileObj) {
|
|
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
|
}
|
|
|
|
const board = await ReactiveCache.getBoard(fileObj.meta?.boardId);
|
|
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
|
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
|
}
|
|
|
|
const isValid = await isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
|
|
|
|
if (!isValid) {
|
|
await Attachments.removeAsync(fileObjId);
|
|
}
|
|
},
|
|
async validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
|
|
check(fileObjId, String);
|
|
check(storageDestination, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
|
}
|
|
|
|
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
|
if (!fileObj) {
|
|
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
|
}
|
|
|
|
const board = await ReactiveCache.getBoard(fileObj.meta?.boardId);
|
|
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
|
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
|
}
|
|
|
|
// Allowlist storage destinations
|
|
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
|
if (!allowedDestinations.includes(storageDestination)) {
|
|
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
|
}
|
|
|
|
await Meteor.callAsync('validateAttachment', fileObjId);
|
|
|
|
const fileObjAfter = await ReactiveCache.getAttachment(fileObjId);
|
|
|
|
if (fileObjAfter) {
|
|
Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
|
|
}
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Startup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Meteor.startup(async () => {
|
|
await Attachments.collection.createIndexAsync({ 'meta.cardId': 1 });
|
|
|
|
// Ensure standard GridFS index on attachments.chunks for efficient chunk lookups.
|
|
// Without this, queries like find({files_id: ObjectId}) do full collection scans.
|
|
const db = MongoInternals.defaultRemoteCollectionDriver().mongo.db;
|
|
const chunksCollection = db.collection('attachments.chunks');
|
|
try {
|
|
await chunksCollection.createIndex({ files_id: 1, n: 1 }, { unique: true });
|
|
} catch (e) {
|
|
// Index already exists, which is fine — skip the error.
|
|
if (e.code !== 86) throw e;
|
|
}
|
|
|
|
const sp = fileStoreStrategyFactory.storagePath;
|
|
if (!fs.existsSync(sp)) {
|
|
console.log("create storagePath because it doesn't exist: " + sp);
|
|
fs.mkdirSync(sp, { recursive: true });
|
|
}
|
|
});
|
|
|