Files
danny-avila--librechat/config/migrate-shared-link-permissions.js
wehub-resource-sync e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

358 lines
12 KiB
JavaScript

const path = require('path');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { ensureRequiredCollectionsExist } = require('@librechat/api');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { findRoleByIdentifier } = require('~/models');
const { SharedLink, AclEntry } = require('~/db/models');
/**
* String literals matching `librechat-data-provider` enums so this script
* runs standalone without requiring a built data-provider package.
*/
const RESOURCE_TYPE_SHARED_LINK = 'sharedLink';
const ROLE_ID_OWNER = 'sharedLink_owner';
const ROLE_ID_VIEWER = 'sharedLink_viewer';
const PRINCIPAL_USER = 'user';
const PRINCIPAL_PUBLIC = 'public';
async function migrateSharedLinkPermissions({
dryRun = true,
batchSize = 100,
force = false,
} = {}) {
await connect();
return runAsSystem(async () => {
logger.info('Starting SharedLink Permissions Migration', { dryRun, batchSize, force });
const mongoose = require('mongoose');
/** @type {import('mongoose').mongo.Db | undefined} */
const db = mongoose.connection.db;
if (db) {
await ensureRequiredCollectionsExist(db);
}
const ownerRole = await findRoleByIdentifier(ROLE_ID_OWNER);
const viewerRole = await findRoleByIdentifier(ROLE_ID_VIEWER);
if (!ownerRole || !viewerRole) {
throw new Error(
'Required sharedLink roles not found (sharedLink_owner, sharedLink_viewer). Run role seeding first.',
);
}
logger.info('Roles resolved', {
owner: { id: ownerRole._id, permBits: ownerRole.permBits },
viewer: { id: viewerRole._id, permBits: viewerRole.permBits },
});
// --- Safety check: abort if isPublic: false documents exist (unless --force) ---
// Raw driver bypasses mongoose strictQuery: true, which silently strips query
// keys absent from the schema. isPublic was removed from the schema by this
// migration, so Mongoose queries like { isPublic: false } become {} (match all).
const rawCollection = mongoose.connection.db.collection('sharedlinks');
const isPublicFalseCount = await rawCollection.countDocuments({ isPublic: false });
if (!dryRun && isPublicFalseCount > 0 && !force) {
const sample = await rawCollection
.find({ isPublic: false })
.project({ _id: 1, shareId: 1, user: 1 })
.limit(20)
.toArray();
const sampleIds = sample.map((doc) => doc._id.toString());
logger.error(
`Found ${isPublicFalseCount} SharedLink documents with isPublic: false. ` +
'These may have been intentionally marked non-public. ' +
'Use --force to proceed anyway (they will NOT receive a PUBLIC VIEWER grant).',
{ sampleIds },
);
return {
aborted: true,
reason: 'isPublic: false documents found',
isPublicFalseCount,
sampleIds,
};
}
// --- Count totals for progress reporting ---
const totalLinks = await SharedLink.countDocuments({});
logger.info(`Found ${totalLinks} SharedLink documents total`);
if (totalLinks === 0) {
logger.info('No SharedLink documents to migrate');
return { migrated: 0, errors: 0, skipped: 0, dryRun };
}
// --- Dry run: scan and categorize ---
if (dryRun) {
const withUser = await SharedLink.countDocuments({ user: { $exists: true, $ne: null } });
const withoutUser = await SharedLink.countDocuments({
$or: [{ user: { $exists: false } }, { user: null }],
});
const withIsPublicTrue = await rawCollection.countDocuments({ isPublic: true });
const withIsPublicFalse = isPublicFalseCount;
const withIsPublicField = await rawCollection.countDocuments({ isPublic: { $exists: true } });
const alreadyMigratedOwner = await AclEntry.countDocuments({
resourceType: RESOURCE_TYPE_SHARED_LINK,
principalType: PRINCIPAL_USER,
});
const alreadyMigratedPublic = await AclEntry.countDocuments({
resourceType: RESOURCE_TYPE_SHARED_LINK,
principalType: PRINCIPAL_PUBLIC,
});
return {
migrated: 0,
errors: 0,
dryRun: true,
summary: {
totalLinks,
withUser,
withoutUser,
withIsPublicTrue,
withIsPublicFalse,
withIsPublicField,
alreadyMigratedOwner,
alreadyMigratedPublic,
},
};
}
// --- Live migration: cursor-based batch processing ---
const failedLinkIds = new Set();
const results = {
migrated: 0,
errors: 0,
skipped: 0,
ownerGrants: 0,
ownerSkipped: 0,
publicViewerGrants: 0,
publicViewerSkipped: 0,
missingUserWarnings: 0,
};
const cursor = SharedLink.find({})
.select('_id user isPublic tenantId expiredAt')
.lean()
.cursor();
let batch = [];
let batchIndex = 0;
/**
* Process a single batch of SharedLink documents.
* Collects upsert operations into a single bulkWrite for efficiency.
* Tracks which op indices map to which link IDs so write failures
* can be attributed to specific links.
*/
async function processBatch(links) {
const bulkOps = [];
const opIndexToLinkId = [];
for (const link of links) {
const linkId = link._id;
const userId = link.user;
const tenantId = link.tenantId;
const expiredAt = link.expiredAt;
const now = new Date();
if (userId) {
opIndexToLinkId.push(linkId);
bulkOps.push({
updateOne: {
filter: {
resourceType: RESOURCE_TYPE_SHARED_LINK,
resourceId: linkId,
principalType: PRINCIPAL_USER,
principalId: new mongoose.Types.ObjectId(userId),
},
update: {
$set: {
permBits: ownerRole.permBits,
roleId: ownerRole._id,
grantedBy: new mongoose.Types.ObjectId(userId),
grantedAt: now,
...(expiredAt && { expiredAt }),
},
$setOnInsert: {
principalModel: 'User',
...(tenantId && { tenantId }),
},
},
upsert: true,
},
});
} else {
results.missingUserWarnings++;
logger.warn('SharedLink has no user field, skipping OWNER grant', {
linkId: linkId.toString(),
});
}
const hasIsPublic = link.isPublic !== undefined;
if (hasIsPublic && link.isPublic === false) {
results.publicViewerSkipped++;
} else if (hasIsPublic) {
const publicUpdateSet = {
permBits: viewerRole.permBits,
roleId: viewerRole._id,
grantedAt: now,
...(expiredAt && { expiredAt }),
};
if (userId) {
publicUpdateSet.grantedBy = new mongoose.Types.ObjectId(userId);
}
opIndexToLinkId.push(linkId);
bulkOps.push({
updateOne: {
filter: {
resourceType: RESOURCE_TYPE_SHARED_LINK,
resourceId: linkId,
principalType: PRINCIPAL_PUBLIC,
},
update: {
$set: publicUpdateSet,
$setOnInsert: {
...(tenantId && { tenantId }),
},
},
upsert: true,
},
});
}
results.migrated++;
}
if (bulkOps.length > 0) {
try {
const bulkResult = await AclEntry.bulkWrite(bulkOps, { ordered: false });
results.ownerGrants += bulkResult.upsertedCount;
} catch (error) {
if (error.writeErrors) {
results.errors += error.writeErrors.length;
for (const writeError of error.writeErrors) {
const failedId = opIndexToLinkId[writeError.index];
if (failedId) {
failedLinkIds.add(failedId);
}
logger.error('Failed to migrate SharedLink in bulk', {
error: writeError.errmsg,
linkId: failedId?.toString(),
});
}
} else {
results.errors += links.length;
for (const link of links) {
failedLinkIds.add(link._id);
}
logger.error('Bulk write failed entirely', { error: error.message });
}
}
}
}
for await (const doc of cursor) {
batch.push(doc);
if (batch.length >= batchSize) {
batchIndex++;
const totalBatches = Math.ceil(totalLinks / batchSize);
if (batchIndex % 5 === 0 || batchIndex === 1) {
logger.info(`Processing batch ${batchIndex}/${totalBatches}`, {
migrated: results.migrated,
errors: results.errors,
});
}
await processBatch(batch);
batch = [];
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// Process remaining documents
if (batch.length > 0) {
batchIndex++;
const totalBatches = Math.ceil(totalLinks / batchSize);
logger.info(`Processing final batch ${batchIndex}/${totalBatches}`, {
remaining: batch.length,
});
await processBatch(batch);
}
// --- $unset isPublic only from successfully migrated documents ---
const unsetFilter = { isPublic: { $exists: true } };
if (failedLinkIds.size > 0) {
unsetFilter._id = { $nin: [...failedLinkIds] };
logger.warn(
`Skipping isPublic removal for ${failedLinkIds.size} links with failed ACL grants`,
{ failedLinkIds: [...failedLinkIds].map((id) => id.toString()) },
);
}
logger.info('Removing isPublic field from successfully migrated SharedLink documents...');
const unsetResult = await rawCollection.updateMany(unsetFilter, { $unset: { isPublic: 1 } });
logger.info(`Removed isPublic field from ${unsetResult.modifiedCount} documents`);
results.isPublicFieldsRemoved = unsetResult.modifiedCount;
results.failedLinkCount = failedLinkIds.size;
logger.info('SharedLink migration completed', results);
return results;
});
}
if (require.main === module) {
const dryRun = process.argv.includes('--dry-run');
const force = process.argv.includes('--force');
const batchSize =
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
migrateSharedLinkPermissions({ dryRun, batchSize, force })
.then((result) => {
if (result.aborted) {
console.log('\n=== MIGRATION ABORTED ===');
console.log(`Reason: ${result.reason}`);
console.log(`Documents with isPublic: false: ${result.isPublicFalseCount}`);
console.log(`Sample IDs: ${result.sampleIds.join(', ')}`);
console.log('\nUse --force to proceed anyway');
process.exit(1);
}
if (dryRun) {
console.log('\n=== DRY RUN RESULTS ===');
console.log(`Total SharedLink documents: ${result.summary.totalLinks}`);
console.log(`- With user field: ${result.summary.withUser}`);
console.log(`- Without user field: ${result.summary.withoutUser}`);
console.log(`- With isPublic: true: ${result.summary.withIsPublicTrue}`);
console.log(`- With isPublic: false: ${result.summary.withIsPublicFalse}`);
console.log(`- With isPublic field present: ${result.summary.withIsPublicField}`);
console.log(
`\nAlready migrated (OWNER AclEntries): ${result.summary.alreadyMigratedOwner}`,
);
console.log(
`Already migrated (PUBLIC AclEntries): ${result.summary.alreadyMigratedPublic}`,
);
console.log('\nTo run the actual migration, remove the --dry-run flag');
} else {
console.log('\n=== MIGRATION RESULTS ===');
console.log(JSON.stringify(result, null, 2));
}
process.exit(0);
})
.catch((error) => {
console.error('SharedLink migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateSharedLinkPermissions };