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.
 
 
 
 
 
 
wekan/models/attachments.server.js

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 });
}
});