back cli: add user dump and list commands

This commit is contained in:
Eric Doughty-Papassideris
2025-02-28 00:52:19 +01:00
committed by Anton Shepilov
parent eec23b230b
commit 8efcb2546c
6 changed files with 439 additions and 0 deletions
@@ -0,0 +1,116 @@
import yargs from "yargs";
import runWithPlatform from "../../lib/run-with-platform";
import gr from "../../../services/global-resolver";
import { messageQueueLogger, platformLogger } from "../../../core/platform/framework";
import {
buildUserDeletionRepositories,
descendDriveItemsDepthFirstRandomOrder,
loadRawVersionsOfItemForDeletion,
type TUserDeletionRepos,
} from "../../../core/platform/services/admin/utils";
import type User from "../../../services/user/entities/user";
import { EntityByKind } from "../../utils/print-entities";
let globalArgv: {
verbose: boolean;
user_id: string;
json_output: boolean;
with_storage_paths: boolean;
user?: User; // Not an argument per say, hydrated in handler
};
const lengthOfLongestEntityKind = Object.keys(EntityByKind).reduce(
(acc, kind) => Math.max(acc, kind.length),
0,
);
const entityToHeaderString = <K extends keyof typeof EntityByKind>(
kind: K & string,
entity: Parameters<(typeof EntityByKind)[K]["headerOf"]>[0],
depth: number = 0,
): string => {
if (globalArgv.json_output) return kind + " " + JSON.stringify(entity); // Can't really print out json directly, valid json seems to get prettified by pino
// ts doesn't seem to infer entityHeaderPrinter[kind] correctly even with help
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return `${kind.padEnd(lengthOfLongestEntityKind)} ${entity.id} ${new Array(depth + 1).join(
" ",
)}${EntityByKind[kind].headerOf(entity as any)}`;
};
async function dumpAccount(repos: TUserDeletionRepos): Promise<number> {
console.log(entityToHeaderString("user", globalArgv.user));
await descendDriveItemsDepthFirstRandomOrder(
repos,
"user_" + globalArgv.user.id,
async (item, _children, parents) => {
const depth = parents.length + 1;
if (!item.is_directory) {
const versionsAssets = await loadRawVersionsOfItemForDeletion(
repos,
item,
globalArgv.with_storage_paths,
);
for (const { version, file, paths } of versionsAssets) {
const outputPath = (path: string) =>
console.log(entityToHeaderString("s3", { id: file.id, path }, depth + 2));
if (globalArgv.json_output) {
for (const path of paths || []) outputPath(path);
} else if (paths) {
paths.sort((a, b) => a.localeCompare(b));
if (paths.length > 0) outputPath(paths[0]);
if (paths.length == 3) outputPath(paths[1]);
else if (paths.length > 3) outputPath(`... skipping ${paths.length - 2} paths ...`);
if (paths.length > 1) outputPath(paths[paths.length - 1]);
}
file && console.log(entityToHeaderString("file", file, depth + 2));
version && console.log(entityToHeaderString("version", version, depth + 1));
}
}
console.log(entityToHeaderString("item", item, depth));
},
);
return 0;
}
export default {
command: "dump <user_id>",
describe: "Output a list of entities related to a user",
builder: {
user_id: {
demandOption: true,
type: "string",
describe:
"id of the user, or a search string for the email.\n(if the id doesn't match, will print the matching users and exit)",
},
json_output: {
type: "boolean",
alias: "j",
describe: "Output in lines of '[kind] [json_object]'.",
},
with_storage_paths: {
type: "boolean",
alias: "p",
describe: "If set, also query the storage to include all relevant paths in the output",
},
},
handler: async (argv: typeof globalArgv) => {
globalArgv = argv;
if (!argv.verbose) {
platformLogger.level = "warn";
messageQueueLogger.level = "warn";
}
await runWithPlatform("dump_account", async ({ spinner: _spinner, platform: _platform }) => {
const repos = await buildUserDeletionRepositories(gr.database, gr.platformServices.search);
argv.user = await repos.user.findOne({ id: argv.user_id });
if (!argv.user) {
console.error(`Error, unknown user ${JSON.stringify(argv.user_id)}`);
return 1;
}
await dumpAccount(repos);
return 0;
});
},
} as yargs.CommandModule<object, typeof globalArgv>;
@@ -0,0 +1,69 @@
import yargs from "yargs";
import runWithPlatform from "../../lib/run-with-platform";
import { messageQueueLogger, platformLogger } from "../../../core/platform/framework";
import { buildUserDeletionRepositories } from "../../../core/platform/services/admin/utils";
import type User from "../../../services/user/entities/user";
import { EntityByKind } from "../../utils/print-entities";
let globalArgv: {
verbose: boolean;
email_like: string;
json_output: boolean;
user?: User; // Not an argument per say, hydrated in handler
};
const entityToHeaderString = <K extends keyof typeof EntityByKind>(
kind: K & string,
entity: Parameters<(typeof EntityByKind)[K]["headerOf"]>[0],
): string => {
if (globalArgv.json_output) return kind + " " + JSON.stringify(entity); // Can't really print out json directly, valid json seems to get prettified by pino
// ts doesn't seem to infer entityHeaderPrinter[kind] correctly even with help
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return `${entity.id} ${EntityByKind[kind].headerOf(entity as any)}`;
};
export default {
command: "list",
describe: "list users",
builder: {
email_like: {
alias: "e",
type: "string",
describe:
"id of the user, or a search string for the email.\n(if the id doesn't match, will print the matching users and exit)",
},
json_output: {
type: "boolean",
alias: "j",
describe: "Output in lines of '[kind] [json_object]'.",
},
},
handler: async (argv: typeof globalArgv) => {
globalArgv = argv;
if (!argv.verbose) {
platformLogger.level = "warn";
messageQueueLogger.level = "warn";
}
await runWithPlatform("dump_account", async ({ spinner: _spinner, platform: _platform }) => {
const repos = await buildUserDeletionRepositories();
console.log(
(
await repos.user.find(
{},
{
...(argv.email_like ? { $like: [["email_canonical", argv.email_like]] } : {}),
sort: { email_canonical: "asc" },
},
)
)
.getEntities()
.map(user => entityToHeaderString("user", user))
.join("\n"),
);
return 0;
});
},
} as yargs.CommandModule<object, typeof globalArgv>;
@@ -35,6 +35,8 @@ export default async function runWithPlatform(
spinner.fail(err.stack || err);
process.exitCode = 1;
}
// Spinner seems to interrupt still buffered output otherwise
await new Promise(resolve => setTimeout(resolve, 200));
spinner.start("Platform: shutting down...");
await platform.stop();
spinner.succeed("Platform: shutdown");
@@ -0,0 +1,92 @@
import type { EntityTarget } from "../../core/platform/services/search/api";
import gr from "../../services/global-resolver";
import User, { TYPE as UserTYPE } from "../../services/user/entities/user";
import { DriveFile, TYPE as DriveFileTYPE } from "../../services/documents/entities/drive-file";
import { File } from "../../services/files/entities/file";
import {
FileVersion,
TYPE as FileVersionTYPE,
} from "../../services/documents/entities/file-version";
import CompanyUser, { TYPE as CompanyUserTYPE } from "../../services/user/entities/company_user";
import {
MissedDriveFile,
TYPE as MissedDriveFileTYPE,
} from "../../services/documents/entities/missed-drive-file";
import Company, { TYPE as CompanyTYPE } from "../../services/user/entities/company";
const FileTYPE = "files";
/** Fake entity to represent S3 path entries as similar resources */
interface S3Object {
/** id of the File */
id: string;
/** path in bucket of the file */
path: string;
}
const epochToISO = epoch => (epoch ? new Date(epoch).toISOString() : "<null date>");
export const EntityKindToString = {
user: (entity: User): string => `🤺 ${entity.email_canonical}`,
// extUser: (entity: ExternalUser): string => `🤺 ${entity.user_id} <-> 🌍 ${entity.external_id}`,
compUser: (entity: CompanyUser): string => `🤺 ${entity.user_id} <-> 🏦 ${entity.group_id}`,
// session: (entity: Session): string => `🤺 ${entity.sub} <-> 🏦 ${entity.company_id}`,
company: (entity: Company): string => `🏦 ${entity.name || entity.displayName}`,
item: (entity: DriveFile): string =>
`${entity.is_in_trash ? "🗑️ " : ""}${entity.is_directory ? "📁" : "📄"}${
entity.scope != "personal" ? "🏢 " : ""
} ${entity.name}`,
version: (entity: FileVersion): string =>
`🕰️ ${epochToISO(entity.date_added)} - ${entity.file_size}`,
file: (entity: File): string => `💿 ${epochToISO(entity.updated_at)}`,
s3: (entity: S3Object): string => `☁️ ${entity.path}`,
missed: (entity: MissedDriveFile): string =>
`😭 ${entity.is_in_trash ? "🗑️ " : ""} ${entity.name}`,
};
const makeEntityKind = <T>(
kind: keyof typeof EntityKindToString,
db?: { entity: EntityTarget<T>; type: string },
) => {
const headerToString = EntityKindToString[kind];
return {
kind,
headerOf: headerToString,
db: db
? {
getRepository: () => gr.database.getRepository<T>(db.type, db.entity),
...db,
}
: undefined,
};
};
export type TEntityNames = keyof typeof Entities;
export const Entities = {
User: makeEntityKind<User>("user", { entity: User, type: UserTYPE }),
DriveFile: makeEntityKind<DriveFile>("item", { entity: DriveFile, type: DriveFileTYPE }),
File: makeEntityKind<File>("file", { entity: File, type: FileTYPE }),
FileVersion: makeEntityKind<FileVersion>("version", {
entity: FileVersion,
type: FileVersionTYPE,
}),
// ExternalUser: makeEntityKind<ExternalUser>("extUser", {
// entity: ExternalUser,
// type: ExternalUserTYPE,
// }),
CompanyUser: makeEntityKind<CompanyUser>("compUser", {
entity: CompanyUser,
type: CompanyUserTYPE,
}),
MissedDriveFile: makeEntityKind<MissedDriveFile>("missed", {
entity: MissedDriveFile,
type: MissedDriveFileTYPE,
}),
Company: makeEntityKind<Company>("company", { entity: Company, type: CompanyTYPE }),
// Session: makeEntityKind<Session>("session", { entity: Session, type: SessionTYPE }),
$S3: makeEntityKind<S3Object>("s3"),
};
export const EntityByKind = Object.fromEntries(
Object.entries(Entities).map(([_, def]) => [def.kind, def]),
);