bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

create-user.js (4354B)


      1 const fs = require("fs");
      2 const path = require("path");
      3 const crypto = require("crypto");
      4 const readline = require("readline");
      5 
      6 const USERS_FILE = process.env.USERS_FILE || path.join(__dirname, "..", "data", "users.json");
      7 
      8 function normalizeUsername(username) {
      9   if (typeof username !== "string") return "";
     10   const cleaned = username.trim().toLowerCase();
     11   if (!cleaned) return "";
     12   if (cleaned.length > 32) return "";
     13   if (!/^[a-z0-9_][a-z0-9_.-]*$/.test(cleaned)) return "";
     14   return cleaned;
     15 }
     16 
     17 function hashPassword(password, saltHex) {
     18   const salt = Buffer.from(saltHex, "hex");
     19   const derived = crypto.scryptSync(String(password), salt, 64);
     20   return derived.toString("hex");
     21 }
     22 
     23 function loadUsers() {
     24   try {
     25     const raw = fs.readFileSync(USERS_FILE, "utf8");
     26     const data = JSON.parse(raw);
     27     const list = Array.isArray(data) ? data : Array.isArray(data?.users) ? data.users : [];
     28     return { version: 1, users: list };
     29   } catch (e) {
     30     if (e?.code === "ENOENT") return { version: 1, users: [] };
     31     throw e;
     32   }
     33 }
     34 
     35 function normalizeUsernameList(users) {
     36   if (!Array.isArray(users)) return [];
     37   return users.filter((u) => u && typeof u === "object");
     38 }
     39 
     40 function saveUsers(data) {
     41   fs.mkdirSync(path.dirname(USERS_FILE), { recursive: true });
     42   fs.writeFileSync(USERS_FILE, JSON.stringify({ version: 1, users: data.users }, null, 2) + "\n", "utf8");
     43 }
     44 
     45 async function promptHidden(query) {
     46   return await new Promise((resolve) => {
     47     // Some Windows shells / double-clicked wrappers can behave oddly with manual stdin listeners.
     48     // Use readline's built-in masking approach, and fall back to visible input when not a TTY.
     49     if (!process.stdin.isTTY || !process.stdout.isTTY) {
     50       const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
     51       rl.question(`${query}(input will be visible) `, (value) => {
     52         rl.close();
     53         resolve(value);
     54       });
     55       return;
     56     }
     57 
     58     const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
     59     rl.stdoutMuted = true;
     60     // eslint-disable-next-line no-underscore-dangle
     61     rl._writeToOutput = function _writeToOutput(stringToWrite) {
     62       const s = String(stringToWrite || "");
     63       if (!rl.stdoutMuted) return rl.output.write(s);
     64       // Preserve newlines (enter) so the prompt advances properly.
     65       if (s === "\n" || s === "\r\n" || s === "\r") return rl.output.write(s);
     66       return rl.output.write("*");
     67     };
     68 
     69     try {
     70       rl.output.write(query);
     71     } catch {
     72       // ignore
     73     }
     74     rl.question("", (value) => {
     75       rl.history = rl.history.slice(1);
     76       rl.close();
     77       try {
     78         process.stdout.write("\n");
     79       } catch {
     80         // ignore
     81       }
     82       resolve(value);
     83     });
     84   });
     85 }
     86 
     87 async function main() {
     88   const rawUsername = process.argv[2] || "";
     89   const username = normalizeUsername(rawUsername);
     90   if (!username) {
     91     console.error('Usage: node scripts/create-user.js <username>\nAllowed: a-z 0-9 _ . - (max 32 chars)');
     92     process.exit(1);
     93   }
     94 
     95   const password = await promptHidden("Password: ");
     96   const confirm = await promptHidden("Confirm:  ");
     97   if (password !== confirm) {
     98     console.error("\nPasswords did not match.");
     99     process.exit(1);
    100   }
    101   if (password.length < 4) {
    102     console.error("\nPassword too short (min 4).");
    103     process.exit(1);
    104   }
    105 
    106   const data = loadUsers();
    107   data.users = normalizeUsernameList(data.users);
    108   const exists = (data.users || []).some((u) => normalizeUsername(u?.username) === username);
    109   if (exists) {
    110     console.error(`\nUser "${username}" already exists in ${USERS_FILE}`);
    111     process.exit(1);
    112   }
    113 
    114   const salt = crypto.randomBytes(16).toString("hex");
    115   const hash = hashPassword(password, salt);
    116   const isFirst = data.users.length === 0;
    117   const role = isFirst ? "owner" : "member";
    118   data.users.push({
    119     username,
    120     salt,
    121     hash,
    122     role,
    123     customRoles: [],
    124     mutedUntil: 0,
    125     suspendedUntil: 0,
    126     banned: false,
    127     pronouns: "",
    128     bioHtml: "",
    129     themeSongUrl: "",
    130     links: [],
    131     starredPostIds: [],
    132     hiddenPostIds: [],
    133     createdAt: Date.now()
    134   });
    135   saveUsers(data);
    136   console.log(`\nCreated "${username}" (${role}) in ${USERS_FILE}`);
    137 }
    138 
    139 main().catch((err) => {
    140   console.error("Failed:", err?.message || err);
    141   process.exit(1);
    142 });