mirror of
https://github.com/logseq/logseq.git
synced 2026-05-15 08:22:23 +00:00
255 lines
8.0 KiB
JavaScript
255 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const path = require("node:path");
|
|
const readline = require("node:readline/promises");
|
|
const { stdin, stdout } = require("node:process");
|
|
const { parseArgs } = require("node:util");
|
|
const {
|
|
buildAdminGraphDeleteUrl,
|
|
buildUserGraphsSql,
|
|
buildWranglerArgs,
|
|
defaultConfigPath,
|
|
fail,
|
|
formatUserGraphsResult,
|
|
parseWranglerResults,
|
|
printUserGraphsTable,
|
|
runWranglerQuery,
|
|
} = require("./graph_user_lib");
|
|
|
|
function printHelp() {
|
|
console.log(`Delete a db-sync user and all related data from a remote D1 environment.
|
|
|
|
Usage:
|
|
node worker/scripts/delete_user_totally.js --username <username> [--env prod]
|
|
node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
|
|
|
|
Options:
|
|
--username <username> Look up the target user by username.
|
|
--user-id <user-id> Look up the target user by user id.
|
|
--env <env> Wrangler environment to use. Defaults to "prod".
|
|
--database <name> D1 binding or database name. Defaults to "DB".
|
|
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
|
|
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
|
|
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
|
|
--help Show this message.
|
|
`);
|
|
}
|
|
|
|
function parseCliArgs(argv) {
|
|
const { values } = parseArgs({
|
|
args: argv,
|
|
options: {
|
|
username: { type: "string" },
|
|
"user-id": { type: "string" },
|
|
env: { type: "string", default: "prod" },
|
|
database: { type: "string", default: "DB" },
|
|
config: { type: "string", default: defaultConfigPath },
|
|
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
|
|
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
|
|
help: { type: "boolean", default: false },
|
|
},
|
|
strict: true,
|
|
allowPositionals: false,
|
|
});
|
|
|
|
if (values.help) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
|
|
if (lookupCount !== 1) {
|
|
fail("Pass exactly one of --username or --user-id.");
|
|
}
|
|
|
|
return {
|
|
lookupField: values.username ? "username" : "id",
|
|
lookupLabel: values.username ? "username" : "user-id",
|
|
lookupValue: values.username ?? values["user-id"],
|
|
env: values.env,
|
|
database: values.database,
|
|
config: path.resolve(values.config),
|
|
baseUrl: values["base-url"],
|
|
adminToken: values["admin-token"],
|
|
};
|
|
}
|
|
|
|
function escapeSqlValue(value) {
|
|
return value.replaceAll("'", "''");
|
|
}
|
|
|
|
function runSelectQuery(options, sql) {
|
|
const wranglerArgs = buildWranglerArgs({
|
|
database: options.database,
|
|
config: options.config,
|
|
env: options.env,
|
|
sql,
|
|
});
|
|
|
|
return parseWranglerResults(runWranglerQuery(wranglerArgs));
|
|
}
|
|
|
|
function runMutationQuery(options, sql) {
|
|
const wranglerArgs = buildWranglerArgs({
|
|
database: options.database,
|
|
config: options.config,
|
|
env: options.env,
|
|
sql,
|
|
});
|
|
|
|
const output = runWranglerQuery(wranglerArgs);
|
|
if (!Array.isArray(output) || output.length === 0) {
|
|
throw new Error("Unexpected empty response from wrangler.");
|
|
}
|
|
|
|
output.forEach((statement, index) => {
|
|
if (!statement.success) {
|
|
throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
|
|
}
|
|
});
|
|
|
|
return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
|
|
}
|
|
|
|
function sqlCountToNumber(value) {
|
|
const numericValue = Number(value);
|
|
return Number.isFinite(numericValue) ? numericValue : 0;
|
|
}
|
|
|
|
function isDeleteConfirmationAccepted(answer, userId) {
|
|
const normalizedAnswer = answer.trim();
|
|
return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
|
|
}
|
|
|
|
async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
|
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
try {
|
|
const answer = await rl.question(
|
|
`Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
|
|
);
|
|
return isDeleteConfirmationAccepted(answer, user.user_id);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function deleteOwnedGraphs(options, ownedGraphs) {
|
|
for (const graph of ownedGraphs) {
|
|
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
|
|
method: "DELETE",
|
|
headers: {
|
|
"x-db-sync-admin-token": options.adminToken,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.text();
|
|
fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseCliArgs(process.argv.slice(2));
|
|
const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
|
|
const result = formatUserGraphsResult(graphRows);
|
|
|
|
if (!result) {
|
|
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
|
|
}
|
|
|
|
const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
|
|
const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
|
|
|
|
printUserGraphsTable(result, "Graphs linked to user");
|
|
console.log(`Owned graphs: ${ownedGraphs.length}`);
|
|
console.log(`Member graphs: ${memberGraphs.length}`);
|
|
|
|
if (ownedGraphs.length > 0 && !options.baseUrl) {
|
|
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
|
|
}
|
|
|
|
if (ownedGraphs.length > 0 && !options.adminToken) {
|
|
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
|
|
}
|
|
|
|
const confirmed = await confirmDeletion({
|
|
user: result.user,
|
|
ownedGraphsCount: ownedGraphs.length,
|
|
memberGraphsCount: memberGraphs.length,
|
|
});
|
|
|
|
if (!confirmed) {
|
|
console.log("Aborted.");
|
|
return;
|
|
}
|
|
|
|
if (ownedGraphs.length > 0) {
|
|
await deleteOwnedGraphs(options, ownedGraphs);
|
|
}
|
|
|
|
const escapedUserId = escapeSqlValue(result.user.user_id);
|
|
const remainingOwnedGraphRows = runSelectQuery(
|
|
options,
|
|
`select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
|
|
);
|
|
const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
|
|
if (remainingOwnedGraphCount > 0) {
|
|
fail(
|
|
`Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
|
|
);
|
|
}
|
|
|
|
const deletedGraphAesKeys = runMutationQuery(
|
|
options,
|
|
`delete from graph_aes_keys where user_id = '${escapedUserId}'`,
|
|
);
|
|
const deletedGraphMembers = runMutationQuery(
|
|
options,
|
|
`delete from graph_members where user_id = '${escapedUserId}'`,
|
|
);
|
|
const clearedInvitedBy = runMutationQuery(
|
|
options,
|
|
`update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
|
|
);
|
|
const deletedUserRsaKeys = runMutationQuery(
|
|
options,
|
|
`delete from user_rsa_keys where user_id = '${escapedUserId}'`,
|
|
);
|
|
const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
|
|
|
|
if (deletedUsers !== 1) {
|
|
fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
|
|
}
|
|
|
|
const userRowsAfterDelete = runSelectQuery(
|
|
options,
|
|
`select id from users where id = '${escapedUserId}' limit 1`,
|
|
);
|
|
if (userRowsAfterDelete.length > 0) {
|
|
fail(`User ${result.user.user_id} still exists after deletion.`);
|
|
}
|
|
|
|
console.table([
|
|
{ step: "owned graphs deleted", rows: ownedGraphs.length },
|
|
{ step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
|
|
{ step: "graph_members deleted", rows: deletedGraphMembers },
|
|
{ step: "graph_members invited_by cleared", rows: clearedInvitedBy },
|
|
{ step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
|
|
{ step: "users deleted", rows: deletedUsers },
|
|
]);
|
|
console.log(`Deleted user ${result.user.user_id} successfully.`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
fail(error instanceof Error ? error.message : String(error));
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
confirmDeletion,
|
|
isDeleteConfirmationAccepted,
|
|
parseCliArgs,
|
|
};
|