Files
wekan/models/lib/collectionFsStore.js
T
Lauri Ojansivu d30803276d - **Fixed Admin Panel / Attachments / Move Attachment doing nothing / crashing.**
Several issues in the bulk move:
- The GridFS source matcher required `meta.gridFsFileId` to be *absent*, but
  Meteor-Files always sets it, so selecting "MongoDB Meteor-Files" matched zero
  files and "nothing happened". The matcher now recognizes real GridFS files
  (`versions.*.storage === 'gridfs'` or a `meta.gridFsFileId` reference), and a
  "nothing to move" message is shown when a source is empty instead of silently
  doing nothing.
- Moving files crashed the whole server with
  `FilesCollection#findOne() not available in server` — `ReactiveCache.getAttachment`
  used a synchronous `findOne()`; it now uses `findOneAsync()`. The background
  job is also hardened so a single failing file is skipped instead of crashing
  the server via an unhandled rejection.
- The attachment **copy** API now honours the admin API upload limits (it
  previously skipped them).
- **Fixed the "MongoDB Meteor-Files" file-count statistic** in Admin Panel /
  Attachments, which counted *every* attachment metadata document (so files on
  the Filesystem were wrongly reported as being in GridFS). It now counts only
  attachments actually stored in GridFS, consistent with the move tool. Also
  renamed the mislabeled "Mongo-Files" column to "Meteor-Files". Thanks to Claude.
- **Unified attachment/avatar storage migration: move any → any.** Admin Panel /
  Attachments / Move Attachment can now move **Attachments, Avatars, or both**,
  from **any source to any destination** across Filesystem, Meteor-Files GridFS,
  Cloud (S3/Azure/GCS) and legacy CollectionFS GridFS. The default source is
  "All Read-enabled storages" (every backend whose Read flag is enabled and whose
  settings work), so everything can be consolidated into one destination in a
  single run. All metadata is preserved (board / swimlane / list / card / user /
  uploaded date / name / type / size), attachment cover references
  (`cards.coverId`) are remapped when an id changes, and the legacy source is
  deleted only after the new copy is verified. Thanks to Claude.
- **Legacy CollectionFS GridFS is now a first-class storage backend** (read,
  migrate-from, and export-to) for both attachments and avatars, via the new
  `models/lib/collectionFsStore.js` (binary keyed by `copies.<coll>.key` in the
  `cfs_gridfs.<coll>` bucket, metadata in `cfs.<coll>.filerecord`). Thanks to Claude.
- **The storage strategy layer is now collection-aware.** `moveToStorage` and the
  GridFS/Cloud strategies previously hard-coded the `Attachments` collection, so
  moving avatars to GridFS/cloud would have updated the wrong collection. The
  factory now carries its collection (`Attachments` or `Avatars`), so **avatars
  can be stored in Meteor-Files GridFS and cloud**, not only on the filesystem.
  Attachment behavior is unchanged. Thanks to Claude.
- **Read legacy CollectionFS attachments and avatars in place** (without
  migrating). The backward-compatibility layer
  (`models/lib/attachmentBackwardCompatibility.js`) was broken — it looked up the
  GridFS binary by the filerecord `_id` and by filename instead of by
  `ObjectId(copies.<coll>.key)`, so legacy files were never found. It is fixed and
  generalized for attachments and avatars. Legacy attachments now appear in the
  card attachment gallery (new `legacyBoardAttachments` publication) and stream
  from the `cfs_gridfs.attachments` bucket, and legacy avatars
  (`/cfs/files/avatars/<id>` URLs) are served from the `cfs_gridfs.avatars`
  bucket instead of redirecting to a 404.

Thanks to Claude.
2026-06-06 02:06:16 +03:00

175 lines
5.7 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { MongoInternals } from 'meteor/mongo';
import { Random } from 'meteor/random';
/**
* Read/write/delete helpers for the legacy CollectionFS GridFS layout, used by
* the unified attachment/avatar storage migration.
*
* Verified structure (from a real WeKan 6.09 mongorestore):
*
* cfs.<coll>.filerecord (metadata, _id = Meteor random id)
* { _id, original:{name,size,type},
* boardId, swimlaneId, listId, cardId, userId, uploadedAt, // top level
* copies: { <coll>: { name, type, size, key:<gridfs file _id hex>,
* updatedAt, createdAt } } }
*
* cfs_gridfs.<coll>.files / .chunks (GridFS bucket; file _id = copies.<coll>.key)
* files: { _id:ObjectId, filename, contentType, length, chunkSize, uploadDate, md5 }
*
* `coll` is 'attachments' or 'avatars'.
*/
function getDb() {
const db = MongoInternals.defaultRemoteCollectionDriver()?.mongo?.db;
if (!db) {
throw new Meteor.Error('mongo-unavailable', 'MongoDB connection is not available');
}
return db;
}
function getBucket(db, coll) {
const { GridFSBucket } = MongoInternals.NpmModule;
return new GridFSBucket(db, { bucketName: `cfs_gridfs.${coll}` });
}
function toObjectId(hexOrId) {
const { ObjectId } = MongoInternals.NpmModule;
if (hexOrId instanceof ObjectId) return hexOrId;
return new ObjectId(String(hexOrId));
}
function filerecordCollName(coll) {
return `cfs.${coll}.filerecord`;
}
// Normalize a filerecord into the common shape the migration engine consumes.
export function normalizeCollectionFsRecord(coll, rec) {
const copy = (rec.copies && rec.copies[coll]) || {};
const key = copy.key;
return {
backend: 'collectionfs',
coll,
sourceId: rec._id,
gridFsKey: key,
name: (rec.original && rec.original.name) || copy.name || rec._id,
type: (rec.original && rec.original.type) || copy.type || 'application/octet-stream',
size: (rec.original && rec.original.size) || copy.size || 0,
// CollectionFS keeps board/card ids at the top level; Meteor-Files nests
// them under meta. Carry them in a normalized `meta` plus the owner userId.
meta: {
boardId: rec.boardId,
swimlaneId: rec.swimlaneId,
listId: rec.listId,
cardId: rec.cardId,
},
userId: rec.userId,
uploadedAt: rec.uploadedAt || (copy.createdAt) || new Date(),
};
}
// List all CollectionFS records for a collection ('attachments' | 'avatars').
export async function listCollectionFsRecords(coll) {
if (!Meteor.isServer) return [];
const db = getDb();
let recs = [];
try {
recs = await db.collection(filerecordCollName(coll)).find({}).toArray();
} catch (error) {
return [];
}
return recs
.map(rec => normalizeCollectionFsRecord(coll, rec))
.filter(r => r.gridFsKey); // only records whose binary key is present
}
export async function countCollectionFsRecords(coll) {
if (!Meteor.isServer) return 0;
const db = getDb();
try {
return await db.collection(filerecordCollName(coll)).countDocuments({});
} catch (error) {
return 0;
}
}
// Read the binary for a normalized CollectionFS record into a Buffer.
export async function readCollectionFsBuffer(item) {
const db = getDb();
const bucket = getBucket(db, item.coll);
const gridFsId = toObjectId(item.gridFsKey);
return await new Promise((resolve, reject) => {
const chunks = [];
const stream = bucket.openDownloadStream(gridFsId);
stream.on('data', c => chunks.push(c));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
// Write a buffer into the CollectionFS layout (GridFS bucket + filerecord),
// reproducing the genuine old-WeKan structure. `info` carries the normalized
// fields (name, type, size, meta{boardId,...}, userId, uploadedAt).
export async function writeCollectionFsRecord(coll, info, buffer) {
const db = getDb();
const bucket = getBucket(db, coll);
// 1. Write the binary; the GridFS file _id becomes the filerecord "key".
const gridFsId = await new Promise((resolve, reject) => {
const uploadStream = bucket.openUploadStream(info.name, {
contentType: info.type,
});
uploadStream.on('error', reject);
uploadStream.on('finish', () => resolve(uploadStream.id));
uploadStream.end(buffer);
});
const now = info.uploadedAt || new Date();
const recordId = Random.id();
const filerecord = {
_id: recordId,
original: {
name: info.name,
size: info.size || (buffer ? buffer.length : 0),
type: info.type,
updatedAt: now,
},
boardId: info.meta && info.meta.boardId,
swimlaneId: info.meta && info.meta.swimlaneId,
listId: info.meta && info.meta.listId,
cardId: info.meta && info.meta.cardId,
userId: info.userId,
uploadedAt: now,
copies: {
[coll]: {
name: info.name,
type: info.type,
size: info.size || (buffer ? buffer.length : 0),
key: gridFsId.toString(),
updatedAt: now,
createdAt: now,
},
},
};
await db.collection(filerecordCollName(coll)).insertOne(filerecord);
return { sourceId: recordId, gridFsKey: gridFsId.toString() };
}
// Delete a CollectionFS record's filerecord and its GridFS binary.
export async function deleteCollectionFsRecord(coll, sourceId, gridFsKey) {
const db = getDb();
try {
await db.collection(filerecordCollName(coll)).deleteOne({ _id: sourceId });
} catch (error) {
console.error('[collectionFsStore] Failed to delete filerecord', sourceId, error);
}
if (gridFsKey) {
try {
const bucket = getBucket(db, coll);
await bucket.delete(toObjectId(gridFsKey));
} catch (error) {
// file/chunks may already be gone
}
}
}