@plank-cms/plank 0.30.1 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/assets/index-laQsuJ53.js +223 -0
- package/dist/admin/index.html +1 -1
- package/dist/index.js +2 -2
- package/dist/migrations/034_password_reset_tokens.sql +14 -0
- package/dist/{server-BDQYF2ZA.js → server-JNWXLTI3.js} +320 -35
- package/dist/templates/mail/base.hbs +55 -0
- package/dist/templates/mail/partials/footer.hbs +9 -0
- package/dist/templates/mail/partials/header.hbs +13 -0
- package/dist/templates/mail/password-reset.hbs +25 -0
- package/package.json +13 -11
- package/dist/admin/assets/index-RfZ7f9RV.js +0 -223
package/dist/admin/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap"
|
|
13
13
|
rel="stylesheet"
|
|
14
14
|
/>
|
|
15
|
-
<script type="module" crossorigin src="/admin/assets/index-
|
|
15
|
+
<script type="module" crossorigin src="/admin/assets/index-laQsuJ53.js"></script>
|
|
16
16
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-CHJ8C--M.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
package/dist/index.js
CHANGED
|
@@ -93,7 +93,7 @@ function getUpdateScriptCommand(name) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// src/commands/init.ts
|
|
96
|
-
var PACKAGE_VERSION = "0.
|
|
96
|
+
var PACKAGE_VERSION = "0.31.1";
|
|
97
97
|
function generateSecret() {
|
|
98
98
|
return randomBytes(32).toString("hex");
|
|
99
99
|
}
|
|
@@ -203,7 +203,7 @@ import { dirname, join as join3, resolve as resolve2 } from "path";
|
|
|
203
203
|
async function start() {
|
|
204
204
|
config({ path: resolve2(process.cwd(), ".env") });
|
|
205
205
|
process.env.PLANK_ADMIN_DIST = join3(dirname(fileURLToPath(import.meta.url)), "admin");
|
|
206
|
-
const { start: startServer } = await import("./server-
|
|
206
|
+
const { start: startServer } = await import("./server-JNWXLTI3.js");
|
|
207
207
|
await startServer();
|
|
208
208
|
}
|
|
209
209
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS plank_password_reset_tokens (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
user_id TEXT NOT NULL REFERENCES plank_users(id) ON DELETE CASCADE,
|
|
4
|
+
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
|
5
|
+
expires_at TIMESTAMP NOT NULL,
|
|
6
|
+
used_at TIMESTAMP,
|
|
7
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE INDEX IF NOT EXISTS idx_plank_password_reset_tokens_user_id
|
|
11
|
+
ON plank_password_reset_tokens(user_id);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_plank_password_reset_tokens_expires_at
|
|
14
|
+
ON plank_password_reset_tokens(expires_at);
|
|
@@ -653,8 +653,8 @@ import express from "express";
|
|
|
653
653
|
import cors from "cors";
|
|
654
654
|
import helmet from "helmet";
|
|
655
655
|
import { readFile as readFile5 } from "fs/promises";
|
|
656
|
-
import { join as
|
|
657
|
-
import { fileURLToPath as
|
|
656
|
+
import { join as join7, dirname as dirname4 } from "path";
|
|
657
|
+
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
658
658
|
|
|
659
659
|
// ../core/dist/routes/auth.js
|
|
660
660
|
import { Router } from "express";
|
|
@@ -662,6 +662,7 @@ import { Router } from "express";
|
|
|
662
662
|
// ../core/dist/controllers/auth.js
|
|
663
663
|
import bcrypt from "bcryptjs";
|
|
664
664
|
import jwt from "jsonwebtoken";
|
|
665
|
+
import { createHash, randomBytes as randomBytes6 } from "crypto";
|
|
665
666
|
|
|
666
667
|
// ../../node_modules/.pnpm/@otplib+core@13.4.0/node_modules/@otplib/core/dist/index.js
|
|
667
668
|
var i = class extends Error {
|
|
@@ -2143,7 +2144,8 @@ function decrypt(stored) {
|
|
|
2143
2144
|
|
|
2144
2145
|
// ../core/dist/lib/settings.js
|
|
2145
2146
|
var SENSITIVE_FIELDS = {
|
|
2146
|
-
media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"])
|
|
2147
|
+
media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"]),
|
|
2148
|
+
mailing: /* @__PURE__ */ new Set(["smtp.password"])
|
|
2147
2149
|
};
|
|
2148
2150
|
function isSensitive(namespace, key) {
|
|
2149
2151
|
return SENSITIVE_FIELDS[namespace]?.has(key) ?? false;
|
|
@@ -2474,6 +2476,113 @@ async function resolveUniquePublicAuthorSlug(input, excludeUserId) {
|
|
|
2474
2476
|
}
|
|
2475
2477
|
}
|
|
2476
2478
|
|
|
2479
|
+
// ../core/dist/lib/mailer.js
|
|
2480
|
+
function parseBoolean(value, fallback = false) {
|
|
2481
|
+
if (value == null)
|
|
2482
|
+
return fallback;
|
|
2483
|
+
return value.toLowerCase() === "true";
|
|
2484
|
+
}
|
|
2485
|
+
function parsePort(value) {
|
|
2486
|
+
const port = Number(value ?? 587);
|
|
2487
|
+
return Number.isFinite(port) ? port : 587;
|
|
2488
|
+
}
|
|
2489
|
+
function formatFrom(name, email) {
|
|
2490
|
+
if (!name.trim())
|
|
2491
|
+
return email;
|
|
2492
|
+
return `"${name.replaceAll('"', '\\"')}" <${email}>`;
|
|
2493
|
+
}
|
|
2494
|
+
async function getMailingSettings() {
|
|
2495
|
+
const settings = await getSettings("mailing");
|
|
2496
|
+
return {
|
|
2497
|
+
enabled: parseBoolean(settings.enabled),
|
|
2498
|
+
host: settings["smtp.host"] ?? "",
|
|
2499
|
+
port: parsePort(settings["smtp.port"]),
|
|
2500
|
+
secure: parseBoolean(settings["smtp.secure"]),
|
|
2501
|
+
user: settings["smtp.user"] ?? "",
|
|
2502
|
+
password: settings["smtp.password"] ?? "",
|
|
2503
|
+
fromEmail: settings["from.email"] ?? "",
|
|
2504
|
+
fromName: settings["from.name"] ?? "Plank CMS",
|
|
2505
|
+
replyTo: settings.reply_to ?? ""
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
async function sendMail(input) {
|
|
2509
|
+
const settings = await getMailingSettings();
|
|
2510
|
+
if (!settings.enabled)
|
|
2511
|
+
throw new Error("Mailing is disabled.");
|
|
2512
|
+
if (!settings.host || !settings.user || !settings.password || !settings.fromEmail) {
|
|
2513
|
+
throw new Error("Mailing is not configured.");
|
|
2514
|
+
}
|
|
2515
|
+
const nodemailer = await import("nodemailer");
|
|
2516
|
+
const transporter = nodemailer.createTransport({
|
|
2517
|
+
host: settings.host,
|
|
2518
|
+
port: settings.port,
|
|
2519
|
+
secure: settings.secure,
|
|
2520
|
+
auth: {
|
|
2521
|
+
user: settings.user,
|
|
2522
|
+
pass: settings.password
|
|
2523
|
+
}
|
|
2524
|
+
});
|
|
2525
|
+
await transporter.sendMail({
|
|
2526
|
+
from: formatFrom(settings.fromName, settings.fromEmail),
|
|
2527
|
+
to: input.to,
|
|
2528
|
+
subject: input.subject,
|
|
2529
|
+
html: input.html,
|
|
2530
|
+
text: input.text,
|
|
2531
|
+
replyTo: settings.replyTo || void 0
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// ../core/dist/lib/mailTemplates.js
|
|
2536
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
2537
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
2538
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2539
|
+
import handlebars from "handlebars";
|
|
2540
|
+
var templates = /* @__PURE__ */ new Map();
|
|
2541
|
+
var partialsLoaded = false;
|
|
2542
|
+
function templatesRoot() {
|
|
2543
|
+
const moduleDir = dirname2(fileURLToPath2(import.meta.url));
|
|
2544
|
+
const candidates = [
|
|
2545
|
+
join4(process.cwd(), "templates"),
|
|
2546
|
+
join4(process.cwd(), "packages/core/templates"),
|
|
2547
|
+
join4(moduleDir, "../../templates")
|
|
2548
|
+
];
|
|
2549
|
+
const root = candidates.find((candidate) => existsSync(candidate));
|
|
2550
|
+
if (!root)
|
|
2551
|
+
return candidates[0];
|
|
2552
|
+
return root;
|
|
2553
|
+
}
|
|
2554
|
+
function loadPartials() {
|
|
2555
|
+
if (partialsLoaded)
|
|
2556
|
+
return;
|
|
2557
|
+
partialsLoaded = true;
|
|
2558
|
+
const partialsDir = join4(templatesRoot(), "mail", "partials");
|
|
2559
|
+
if (!existsSync(partialsDir))
|
|
2560
|
+
return;
|
|
2561
|
+
for (const file of readdirSync(partialsDir)) {
|
|
2562
|
+
if (!file.endsWith(".hbs"))
|
|
2563
|
+
continue;
|
|
2564
|
+
const name = file.replace(/\.hbs$/, "");
|
|
2565
|
+
const source = readFileSync(join4(partialsDir, file), "utf8");
|
|
2566
|
+
handlebars.registerPartial(name, source);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
handlebars.registerHelper("year", () => (/* @__PURE__ */ new Date()).getFullYear());
|
|
2570
|
+
function loadTemplate(name) {
|
|
2571
|
+
loadPartials();
|
|
2572
|
+
const cached = templates.get(name);
|
|
2573
|
+
if (cached)
|
|
2574
|
+
return cached;
|
|
2575
|
+
const source = readFileSync(join4(templatesRoot(), `${name}.hbs`), "utf8");
|
|
2576
|
+
const compiled = handlebars.compile(source);
|
|
2577
|
+
templates.set(name, compiled);
|
|
2578
|
+
return compiled;
|
|
2579
|
+
}
|
|
2580
|
+
function renderMailTemplate(name, data) {
|
|
2581
|
+
const body = loadTemplate(`mail/${name}`)(data);
|
|
2582
|
+
const base = loadTemplate("mail/base");
|
|
2583
|
+
return base({ ...data, body });
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2477
2586
|
// ../core/dist/controllers/auth.js
|
|
2478
2587
|
var LoginSchema = z.object({
|
|
2479
2588
|
email: z.email(),
|
|
@@ -2489,9 +2598,18 @@ var RegisterSchema = z.object({
|
|
|
2489
2598
|
lastName: z.string().trim().min(1).max(100),
|
|
2490
2599
|
password: z.string().min(8)
|
|
2491
2600
|
});
|
|
2601
|
+
var RequestPasswordResetSchema = z.object({
|
|
2602
|
+
email: z.email()
|
|
2603
|
+
});
|
|
2604
|
+
var ResetPasswordSchema = z.object({
|
|
2605
|
+
token: z.string().min(32),
|
|
2606
|
+
password: z.string().min(8)
|
|
2607
|
+
});
|
|
2492
2608
|
var RATE_LIMIT_WINDOW_MS = 15 * 60 * 1e3;
|
|
2493
2609
|
var LOGIN_RATE_LIMIT_MAX = 10;
|
|
2494
2610
|
var LOGIN_2FA_RATE_LIMIT_MAX = 5;
|
|
2611
|
+
var PASSWORD_RESET_RATE_LIMIT_MAX = 5;
|
|
2612
|
+
var PASSWORD_RESET_EXPIRES_MS = 30 * 60 * 1e3;
|
|
2495
2613
|
var ACCESS_TOKEN_COOKIE = "plank_session";
|
|
2496
2614
|
var ACCESS_TOKEN_EXPIRES_SECONDS = 60 * 60 * 24 * 30;
|
|
2497
2615
|
var REFRESH_TOKEN_COOKIE = "plank_refresh";
|
|
@@ -2567,6 +2685,27 @@ function buildRefreshToken(payload) {
|
|
|
2567
2685
|
function buildChallengeToken(payload) {
|
|
2568
2686
|
return jwt.sign(payload, process.env.PLANK_JWT_SECRET, { expiresIn: "5m" });
|
|
2569
2687
|
}
|
|
2688
|
+
function hashToken(token) {
|
|
2689
|
+
return createHash("sha256").update(token).digest("hex");
|
|
2690
|
+
}
|
|
2691
|
+
function adminBaseUrl(req) {
|
|
2692
|
+
const referer = req.get("referer");
|
|
2693
|
+
if (referer) {
|
|
2694
|
+
try {
|
|
2695
|
+
const url = new URL(referer);
|
|
2696
|
+
if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
|
|
2697
|
+
return `${url.origin}/admin`;
|
|
2698
|
+
}
|
|
2699
|
+
return url.origin;
|
|
2700
|
+
} catch {
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
const origin = req.get("origin");
|
|
2704
|
+
if (origin)
|
|
2705
|
+
return origin;
|
|
2706
|
+
const protocol = req.protocol;
|
|
2707
|
+
return `${protocol}://${req.get("host")}`;
|
|
2708
|
+
}
|
|
2570
2709
|
async function buildAuthPayload(user) {
|
|
2571
2710
|
const { rows: roleRows } = await pool_default.query("SELECT id, name, permissions FROM plank_roles WHERE id = $1", [user.role_id]);
|
|
2572
2711
|
let avatarUrl = user.avatar_url;
|
|
@@ -2592,6 +2731,12 @@ async function buildAuthPayload(user) {
|
|
|
2592
2731
|
}
|
|
2593
2732
|
};
|
|
2594
2733
|
}
|
|
2734
|
+
async function getAuthFeatures(_req, res) {
|
|
2735
|
+
const mailing = await getMailingSettings();
|
|
2736
|
+
res.json({
|
|
2737
|
+
passwordRecovery: mailing.enabled && Boolean(mailing.host && mailing.user && mailing.password && mailing.fromEmail)
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2595
2740
|
async function login(req, res) {
|
|
2596
2741
|
const ip = req.ip ?? "unknown";
|
|
2597
2742
|
const parsed = LoginSchema.safeParse(req.body);
|
|
@@ -2637,6 +2782,95 @@ async function login(req, res) {
|
|
|
2637
2782
|
user: auth.user
|
|
2638
2783
|
});
|
|
2639
2784
|
}
|
|
2785
|
+
async function requestPasswordReset(req, res) {
|
|
2786
|
+
const ip = req.ip ?? "unknown";
|
|
2787
|
+
const parsed = RequestPasswordResetSchema.safeParse(req.body);
|
|
2788
|
+
if (!parsed.success) {
|
|
2789
|
+
res.status(400).json({ errors: flattenError(parsed.error, (i2) => i2.message) });
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
const email = parsed.data.email.toLowerCase();
|
|
2793
|
+
const rateKey = `${ip}:${email}`;
|
|
2794
|
+
if (!await consumeRateLimit("password-reset", rateKey, PASSWORD_RESET_RATE_LIMIT_MAX)) {
|
|
2795
|
+
res.status(429).json({ error: "Too many password reset attempts. Try again in 15 minutes." });
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const mailing = await getMailingSettings();
|
|
2799
|
+
if (!mailing.enabled || !mailing.host || !mailing.user || !mailing.password || !mailing.fromEmail) {
|
|
2800
|
+
res.status(204).end();
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const { rows } = await pool_default.query(`SELECT id, email, first_name, last_name, enabled
|
|
2804
|
+
FROM plank_users
|
|
2805
|
+
WHERE email = $1`, [email]);
|
|
2806
|
+
const user = rows[0];
|
|
2807
|
+
if (!user || !user.enabled) {
|
|
2808
|
+
res.status(204).end();
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
const token = randomBytes6(32).toString("base64url");
|
|
2812
|
+
const resetUrl = `${adminBaseUrl(req)}/reset-password?token=${encodeURIComponent(token)}`;
|
|
2813
|
+
const expiresAt = new Date(Date.now() + PASSWORD_RESET_EXPIRES_MS);
|
|
2814
|
+
await pool_default.query(`INSERT INTO plank_password_reset_tokens (id, user_id, token_hash, expires_at)
|
|
2815
|
+
VALUES ($1, $2, $3, $4)`, [createId(), user.id, hashToken(token), expiresAt]);
|
|
2816
|
+
const html = renderMailTemplate("password-reset", {
|
|
2817
|
+
subject: "Reset your password",
|
|
2818
|
+
resetUrl,
|
|
2819
|
+
expiresIn: "30 minutes",
|
|
2820
|
+
userName: [user.first_name, user.last_name].filter(Boolean).join(" ") || user.email
|
|
2821
|
+
});
|
|
2822
|
+
await sendMail({
|
|
2823
|
+
to: user.email,
|
|
2824
|
+
subject: "Reset your Plank CMS password",
|
|
2825
|
+
html
|
|
2826
|
+
});
|
|
2827
|
+
await clearRateLimit("password-reset", rateKey);
|
|
2828
|
+
res.status(204).end();
|
|
2829
|
+
}
|
|
2830
|
+
async function resetPassword(req, res) {
|
|
2831
|
+
const parsed = ResetPasswordSchema.safeParse(req.body);
|
|
2832
|
+
if (!parsed.success) {
|
|
2833
|
+
res.status(400).json({ errors: flattenError(parsed.error, (i2) => i2.message) });
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
const tokenHash = hashToken(parsed.data.token);
|
|
2837
|
+
const { rows } = await pool_default.query(`SELECT t.id, t.user_id, u.enabled
|
|
2838
|
+
FROM plank_password_reset_tokens t
|
|
2839
|
+
JOIN plank_users u ON u.id = t.user_id
|
|
2840
|
+
WHERE t.token_hash = $1
|
|
2841
|
+
AND t.used_at IS NULL
|
|
2842
|
+
AND t.expires_at > NOW()`, [tokenHash]);
|
|
2843
|
+
const resetToken = rows[0];
|
|
2844
|
+
if (!resetToken || !resetToken.enabled) {
|
|
2845
|
+
res.status(400).json({ error: "Invalid or expired password reset link" });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const hashed = await bcrypt.hash(parsed.data.password, 12);
|
|
2849
|
+
const client = await pool_default.connect();
|
|
2850
|
+
try {
|
|
2851
|
+
await client.query("BEGIN");
|
|
2852
|
+
await client.query(`UPDATE plank_users
|
|
2853
|
+
SET password = $1,
|
|
2854
|
+
two_factor_enabled = FALSE,
|
|
2855
|
+
two_factor_secret = NULL,
|
|
2856
|
+
two_factor_temp_secret = NULL,
|
|
2857
|
+
session_version = session_version + 1
|
|
2858
|
+
WHERE id = $2`, [hashed, resetToken.user_id]);
|
|
2859
|
+
await client.query("DELETE FROM plank_user_backup_codes WHERE user_id = $1", [
|
|
2860
|
+
resetToken.user_id
|
|
2861
|
+
]);
|
|
2862
|
+
await client.query(`UPDATE plank_password_reset_tokens
|
|
2863
|
+
SET used_at = NOW()
|
|
2864
|
+
WHERE user_id = $1 AND used_at IS NULL`, [resetToken.user_id]);
|
|
2865
|
+
await client.query("COMMIT");
|
|
2866
|
+
} catch (err) {
|
|
2867
|
+
await client.query("ROLLBACK");
|
|
2868
|
+
throw err;
|
|
2869
|
+
} finally {
|
|
2870
|
+
client.release();
|
|
2871
|
+
}
|
|
2872
|
+
res.status(204).end();
|
|
2873
|
+
}
|
|
2640
2874
|
async function loginWithTwoFactor(req, res) {
|
|
2641
2875
|
const ip = req.ip ?? "unknown";
|
|
2642
2876
|
const parsed = Login2FASchema.safeParse(req.body);
|
|
@@ -2762,10 +2996,13 @@ async function register(req, res) {
|
|
|
2762
2996
|
// ../core/dist/routes/auth.js
|
|
2763
2997
|
var router = Router();
|
|
2764
2998
|
router.get("/setup", setup);
|
|
2999
|
+
router.get("/features", getAuthFeatures);
|
|
2765
3000
|
router.post("/login", login);
|
|
2766
3001
|
router.post("/login/2fa", loginWithTwoFactor);
|
|
2767
3002
|
router.post("/logout", logout);
|
|
2768
3003
|
router.post("/register", register);
|
|
3004
|
+
router.post("/password-reset", requestPasswordReset);
|
|
3005
|
+
router.post("/password-reset/confirm", resetPassword);
|
|
2769
3006
|
var auth_default = router;
|
|
2770
3007
|
|
|
2771
3008
|
// ../core/dist/routes/admin.js
|
|
@@ -4015,7 +4252,7 @@ var deleteEntry = async (req, res) => {
|
|
|
4015
4252
|
|
|
4016
4253
|
// ../core/dist/controllers/users.js
|
|
4017
4254
|
import bcrypt2 from "bcryptjs";
|
|
4018
|
-
import { randomBytes as
|
|
4255
|
+
import { randomBytes as randomBytes7 } from "crypto";
|
|
4019
4256
|
import { z as z4, flattenError as flattenError4 } from "zod";
|
|
4020
4257
|
var CreateUserSchema = z4.object({
|
|
4021
4258
|
email: z4.email(),
|
|
@@ -4036,6 +4273,9 @@ var ChangePasswordSchema = z4.object({
|
|
|
4036
4273
|
currentPassword: z4.string().min(1),
|
|
4037
4274
|
newPassword: z4.string().min(8)
|
|
4038
4275
|
});
|
|
4276
|
+
var ResetUserPasswordSchema = z4.object({
|
|
4277
|
+
password: z4.string().min(8)
|
|
4278
|
+
});
|
|
4039
4279
|
var UpdateMeSchema = z4.object({
|
|
4040
4280
|
firstName: z4.string().max(100).optional(),
|
|
4041
4281
|
lastName: z4.string().max(100).optional(),
|
|
@@ -4057,7 +4297,7 @@ var RegenerateBackupCodesSchema = z4.object({
|
|
|
4057
4297
|
var BACKUP_CODE_COUNT = 8;
|
|
4058
4298
|
var BACKUP_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
4059
4299
|
function generateBackupCode() {
|
|
4060
|
-
const bytes =
|
|
4300
|
+
const bytes = randomBytes7(8);
|
|
4061
4301
|
let raw = "";
|
|
4062
4302
|
for (let i2 = 0; i2 < 8; i2++) {
|
|
4063
4303
|
raw += BACKUP_CODE_CHARS[bytes[i2] % BACKUP_CODE_CHARS.length];
|
|
@@ -4348,6 +4588,49 @@ async function changePassword(req, res) {
|
|
|
4348
4588
|
await pool_default.query("UPDATE plank_users SET password = $1, session_version = session_version + 1 WHERE id = $2", [hashed, req.user.id]);
|
|
4349
4589
|
res.status(204).end();
|
|
4350
4590
|
}
|
|
4591
|
+
async function resetUserPassword(req, res) {
|
|
4592
|
+
const parsed = ResetUserPasswordSchema.safeParse(req.body);
|
|
4593
|
+
if (!parsed.success) {
|
|
4594
|
+
res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
|
|
4595
|
+
return;
|
|
4596
|
+
}
|
|
4597
|
+
if (req.params.id === req.user.id) {
|
|
4598
|
+
res.status(403).json({ error: "You cannot reset your own password here" });
|
|
4599
|
+
return;
|
|
4600
|
+
}
|
|
4601
|
+
const requesterRoleName = await roleNameById(req.user.roleId);
|
|
4602
|
+
if (requesterRoleName !== "Super Admin") {
|
|
4603
|
+
res.status(403).json({ error: "Only Super Admin can reset user passwords" });
|
|
4604
|
+
return;
|
|
4605
|
+
}
|
|
4606
|
+
const { rows } = await pool_default.query("SELECT id FROM plank_users WHERE id = $1", [
|
|
4607
|
+
req.params.id
|
|
4608
|
+
]);
|
|
4609
|
+
if (!rows[0]) {
|
|
4610
|
+
res.status(404).json({ error: "User not found" });
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
4613
|
+
const hashed = await bcrypt2.hash(parsed.data.password, 12);
|
|
4614
|
+
const client = await pool_default.connect();
|
|
4615
|
+
try {
|
|
4616
|
+
await client.query("BEGIN");
|
|
4617
|
+
await client.query(`UPDATE plank_users
|
|
4618
|
+
SET password = $1,
|
|
4619
|
+
two_factor_enabled = FALSE,
|
|
4620
|
+
two_factor_secret = NULL,
|
|
4621
|
+
two_factor_temp_secret = NULL,
|
|
4622
|
+
session_version = session_version + 1
|
|
4623
|
+
WHERE id = $2`, [hashed, req.params.id]);
|
|
4624
|
+
await client.query("DELETE FROM plank_user_backup_codes WHERE user_id = $1", [req.params.id]);
|
|
4625
|
+
await client.query("COMMIT");
|
|
4626
|
+
} catch (err) {
|
|
4627
|
+
await client.query("ROLLBACK");
|
|
4628
|
+
throw err;
|
|
4629
|
+
} finally {
|
|
4630
|
+
client.release();
|
|
4631
|
+
}
|
|
4632
|
+
res.status(204).end();
|
|
4633
|
+
}
|
|
4351
4634
|
async function createUser(req, res) {
|
|
4352
4635
|
const parsed = CreateUserSchema.safeParse(req.body);
|
|
4353
4636
|
if (!parsed.success) {
|
|
@@ -4462,8 +4745,8 @@ async function deleteUser(req, res) {
|
|
|
4462
4745
|
return;
|
|
4463
4746
|
}
|
|
4464
4747
|
const { rows: roleRows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [rows[0].role_id]);
|
|
4465
|
-
if (roleRows[0]?.name === "Super Admin") {
|
|
4466
|
-
res.status(403).json({ error: "Super Admin
|
|
4748
|
+
if (roleRows[0]?.name === "Super Admin" && await roleNameById(req.user.roleId) !== "Super Admin") {
|
|
4749
|
+
res.status(403).json({ error: "Only Super Admin can delete Super Admin users" });
|
|
4467
4750
|
return;
|
|
4468
4751
|
}
|
|
4469
4752
|
await pool_default.query("DELETE FROM plank_users WHERE id = $1", [req.params.id]);
|
|
@@ -4524,14 +4807,14 @@ async function resetRoles(_req, res) {
|
|
|
4524
4807
|
}
|
|
4525
4808
|
|
|
4526
4809
|
// ../core/dist/controllers/apiTokens.js
|
|
4527
|
-
import { randomBytes as
|
|
4810
|
+
import { randomBytes as randomBytes8, createHash as createHash2 } from "crypto";
|
|
4528
4811
|
import { z as z7, flattenError as flattenError5 } from "zod";
|
|
4529
4812
|
var CreateTokenSchema = z7.object({
|
|
4530
4813
|
name: z7.string().min(1),
|
|
4531
4814
|
accessType: z7.enum(["read-only", "full-access", "mcp-server"])
|
|
4532
4815
|
});
|
|
4533
|
-
function
|
|
4534
|
-
return
|
|
4816
|
+
function hashToken2(token) {
|
|
4817
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
4535
4818
|
}
|
|
4536
4819
|
async function listApiTokens(_req, res) {
|
|
4537
4820
|
const { rows } = await pool_default.query("SELECT id, name, access_type, created_at FROM plank_api_tokens ORDER BY created_at DESC");
|
|
@@ -4545,8 +4828,8 @@ async function createApiToken(req, res) {
|
|
|
4545
4828
|
}
|
|
4546
4829
|
const { name, accessType } = parsed.data;
|
|
4547
4830
|
const id = createId();
|
|
4548
|
-
const token = `plank_${
|
|
4549
|
-
const hashed =
|
|
4831
|
+
const token = `plank_${randomBytes8(32).toString("hex")}`;
|
|
4832
|
+
const hashed = hashToken2(token);
|
|
4550
4833
|
await pool_default.query("INSERT INTO plank_api_tokens (id, name, token, access_type, created_by) VALUES ($1, $2, $3, $4, $5)", [id, name, hashed, accessType, req.user.id]);
|
|
4551
4834
|
res.status(201).json({ id, name, accessType, token });
|
|
4552
4835
|
}
|
|
@@ -4560,7 +4843,7 @@ async function deleteApiToken(req, res) {
|
|
|
4560
4843
|
}
|
|
4561
4844
|
|
|
4562
4845
|
// ../core/dist/controllers/media.js
|
|
4563
|
-
import { randomBytes as
|
|
4846
|
+
import { randomBytes as randomBytes9 } from "crypto";
|
|
4564
4847
|
var MEDIA_PREFIX = "media";
|
|
4565
4848
|
function buildDefaultAlt(filename) {
|
|
4566
4849
|
const baseName = filename.split("/").pop() ?? filename;
|
|
@@ -4641,7 +4924,7 @@ async function uploadMedia(req, res) {
|
|
|
4641
4924
|
res.status(400).json({ error: "No .m3u8 file found in bundle" });
|
|
4642
4925
|
return;
|
|
4643
4926
|
}
|
|
4644
|
-
const bundleId =
|
|
4927
|
+
const bundleId = randomBytes9(8).toString("hex");
|
|
4645
4928
|
const prefix = [MEDIA_PREFIX, folderId, bundleId].filter(Boolean).join("/");
|
|
4646
4929
|
const rootDir = m3u8File.originalname.includes("/") ? m3u8File.originalname.split("/")[0] : null;
|
|
4647
4930
|
const stripRoot = (path) => rootDir && path.startsWith(`${rootDir}/`) ? path.slice(rootDir.length + 1) : path;
|
|
@@ -4867,7 +5150,8 @@ async function deleteFolder(req, res) {
|
|
|
4867
5150
|
|
|
4868
5151
|
// ../core/dist/controllers/settings.js
|
|
4869
5152
|
var SENSITIVE_FIELDS2 = {
|
|
4870
|
-
media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"])
|
|
5153
|
+
media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"]),
|
|
5154
|
+
mailing: /* @__PURE__ */ new Set(["smtp.password"])
|
|
4871
5155
|
};
|
|
4872
5156
|
var MASKED = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
4873
5157
|
function maskSettings(namespace, settings) {
|
|
@@ -4940,8 +5224,8 @@ async function updateNamespaceSettings(req, res) {
|
|
|
4940
5224
|
|
|
4941
5225
|
// ../core/dist/lib/version.js
|
|
4942
5226
|
import { readFile as readFile2 } from "fs/promises";
|
|
4943
|
-
import { join as
|
|
4944
|
-
import { fileURLToPath as
|
|
5227
|
+
import { join as join5 } from "path";
|
|
5228
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4945
5229
|
var PACKAGE_NAME = "@plank-cms/plank";
|
|
4946
5230
|
var CHANGELOG_BASE_URL = "https://github.com/plank-cms/plank/releases";
|
|
4947
5231
|
var REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
@@ -4976,7 +5260,7 @@ function getUpdateCommandForPackageManager(packageManager) {
|
|
|
4976
5260
|
}
|
|
4977
5261
|
async function detectProjectPackageManager() {
|
|
4978
5262
|
try {
|
|
4979
|
-
const raw = await readFile2(
|
|
5263
|
+
const raw = await readFile2(join5(process.cwd(), "package.json"), "utf8");
|
|
4980
5264
|
const parsed = JSON.parse(raw);
|
|
4981
5265
|
if (parsed.packageManager?.startsWith("pnpm@") || parsed.packageManager === "pnpm") {
|
|
4982
5266
|
return "pnpm";
|
|
@@ -4991,7 +5275,7 @@ async function detectProjectPackageManager() {
|
|
|
4991
5275
|
}
|
|
4992
5276
|
async function hasLockfile(filename) {
|
|
4993
5277
|
try {
|
|
4994
|
-
await readFile2(
|
|
5278
|
+
await readFile2(join5(process.cwd(), filename), "utf8");
|
|
4995
5279
|
return true;
|
|
4996
5280
|
} catch {
|
|
4997
5281
|
return false;
|
|
@@ -5009,7 +5293,7 @@ async function detectPackageManagerFromLockfiles() {
|
|
|
5009
5293
|
async function getCurrentVersion() {
|
|
5010
5294
|
for (const packageJsonUrl of packageJsonUrls) {
|
|
5011
5295
|
try {
|
|
5012
|
-
const packageJsonPath =
|
|
5296
|
+
const packageJsonPath = fileURLToPath3(packageJsonUrl);
|
|
5013
5297
|
const raw = await readFile2(packageJsonPath, "utf8");
|
|
5014
5298
|
const parsed = JSON.parse(raw);
|
|
5015
5299
|
if (parsed.version) {
|
|
@@ -5070,12 +5354,12 @@ import { z as z9 } from "zod";
|
|
|
5070
5354
|
// ../core/dist/lib/addons.js
|
|
5071
5355
|
import { access, readFile as readFile3 } from "fs/promises";
|
|
5072
5356
|
import { createRequire } from "module";
|
|
5073
|
-
import { dirname as
|
|
5074
|
-
import { fileURLToPath as
|
|
5357
|
+
import { dirname as dirname3, join as join6, resolve } from "path";
|
|
5358
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
5075
5359
|
import { z as z8 } from "zod";
|
|
5076
5360
|
var ADDON_PACKAGE_PREFIX = "@plank-cms/addon-";
|
|
5077
5361
|
function getHostRequire() {
|
|
5078
|
-
return createRequire(
|
|
5362
|
+
return createRequire(join6(process.cwd(), "package.json"));
|
|
5079
5363
|
}
|
|
5080
5364
|
var addonSlotSchema = z8.object({
|
|
5081
5365
|
id: z8.string().min(1),
|
|
@@ -5170,13 +5454,13 @@ async function resolvePackageJsonPath(packageName) {
|
|
|
5170
5454
|
} catch {
|
|
5171
5455
|
try {
|
|
5172
5456
|
const entryPath = hostRequire.resolve(packageName);
|
|
5173
|
-
let currentDir =
|
|
5457
|
+
let currentDir = dirname3(entryPath);
|
|
5174
5458
|
for (; ; ) {
|
|
5175
|
-
const candidate =
|
|
5459
|
+
const candidate = join6(currentDir, "package.json");
|
|
5176
5460
|
if (await pathExists(candidate)) {
|
|
5177
5461
|
return candidate;
|
|
5178
5462
|
}
|
|
5179
|
-
const parentDir =
|
|
5463
|
+
const parentDir = dirname3(currentDir);
|
|
5180
5464
|
if (parentDir === currentDir)
|
|
5181
5465
|
return null;
|
|
5182
5466
|
currentDir = parentDir;
|
|
@@ -5189,16 +5473,16 @@ async function resolvePackageJsonPath(packageName) {
|
|
|
5189
5473
|
async function resolvePackageRoot(packageName) {
|
|
5190
5474
|
const packageJsonPath = await resolvePackageJsonPath(packageName);
|
|
5191
5475
|
if (packageJsonPath)
|
|
5192
|
-
return
|
|
5476
|
+
return dirname3(packageJsonPath);
|
|
5193
5477
|
try {
|
|
5194
5478
|
const manifestUrl = import.meta.resolve(`${packageName}/plank`);
|
|
5195
|
-
let currentDir =
|
|
5479
|
+
let currentDir = dirname3(fileURLToPath4(manifestUrl));
|
|
5196
5480
|
for (; ; ) {
|
|
5197
|
-
const candidate =
|
|
5481
|
+
const candidate = join6(currentDir, "package.json");
|
|
5198
5482
|
if (await pathExists(candidate)) {
|
|
5199
5483
|
return currentDir;
|
|
5200
5484
|
}
|
|
5201
|
-
const parentDir =
|
|
5485
|
+
const parentDir = dirname3(currentDir);
|
|
5202
5486
|
if (parentDir === currentDir)
|
|
5203
5487
|
return null;
|
|
5204
5488
|
currentDir = parentDir;
|
|
@@ -5219,7 +5503,7 @@ async function readInstalledPackageJson(packageName) {
|
|
|
5219
5503
|
}
|
|
5220
5504
|
}
|
|
5221
5505
|
async function readHostPackageJson() {
|
|
5222
|
-
const raw = await readFile3(
|
|
5506
|
+
const raw = await readFile3(join6(process.cwd(), "package.json"), "utf8");
|
|
5223
5507
|
return JSON.parse(raw);
|
|
5224
5508
|
}
|
|
5225
5509
|
function listDeclaredAddonPackages(packageJson) {
|
|
@@ -5792,6 +6076,7 @@ router2.post("/roles/reset", authorize("settings:roles:write"), resetRoles);
|
|
|
5792
6076
|
router2.get("/users", authorize("settings:users:read"), listUsers);
|
|
5793
6077
|
router2.post("/users", authorize("settings:users:write"), createUser);
|
|
5794
6078
|
router2.put("/users/:id", authorize("settings:users:write"), updateUser);
|
|
6079
|
+
router2.post("/users/:id/password/reset", authorize("settings:users:write"), resetUserPassword);
|
|
5795
6080
|
router2.delete("/users/:id", authorize("settings:users:delete"), deleteUser);
|
|
5796
6081
|
router2.get("/api-tokens", authorize("settings:api-tokens:read"), listApiTokens);
|
|
5797
6082
|
router2.post("/api-tokens", authorize("settings:api-tokens:write"), createApiToken);
|
|
@@ -5832,7 +6117,7 @@ var admin_default = router2;
|
|
|
5832
6117
|
import { Router as Router3 } from "express";
|
|
5833
6118
|
|
|
5834
6119
|
// ../core/dist/middlewares/apiToken.js
|
|
5835
|
-
import { createHash as
|
|
6120
|
+
import { createHash as createHash3 } from "crypto";
|
|
5836
6121
|
var READ_ONLY_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
5837
6122
|
var PUBLIC_API_ACCESS_TYPES = /* @__PURE__ */ new Set(["read-only", "full-access"]);
|
|
5838
6123
|
var MCP_ACCESS_TYPES = /* @__PURE__ */ new Set(["mcp-server"]);
|
|
@@ -5843,7 +6128,7 @@ async function enforceApiToken(req, res, next, allowedAccessTypes) {
|
|
|
5843
6128
|
return;
|
|
5844
6129
|
}
|
|
5845
6130
|
const raw = header.slice(7);
|
|
5846
|
-
const hashed =
|
|
6131
|
+
const hashed = createHash3("sha256").update(raw).digest("hex");
|
|
5847
6132
|
const { rows } = await pool_default.query("SELECT id, access_type FROM plank_api_tokens WHERE token = $1", [hashed]);
|
|
5848
6133
|
if (!rows[0]) {
|
|
5849
6134
|
res.status(401).json({ error: "Invalid API token" });
|
|
@@ -6821,11 +7106,11 @@ if (isDev) {
|
|
|
6821
7106
|
app.get("/admin/*path", (_req, res) => res.redirect(adminDevUrl));
|
|
6822
7107
|
app.get("/admin", (_req, res) => res.redirect(adminDevUrl));
|
|
6823
7108
|
} else {
|
|
6824
|
-
const adminDist = process.env.PLANK_ADMIN_DIST ??
|
|
7109
|
+
const adminDist = process.env.PLANK_ADMIN_DIST ?? join7(dirname4(fileURLToPath5(import.meta.url)), "../public/admin");
|
|
6825
7110
|
app.use("/admin", express.static(adminDist));
|
|
6826
7111
|
app.get("/admin/*path", async (_req, res) => {
|
|
6827
7112
|
try {
|
|
6828
|
-
const source = await readFile5(
|
|
7113
|
+
const source = await readFile5(join7(adminDist, "index.html"), "utf8");
|
|
6829
7114
|
res.type("text/html");
|
|
6830
7115
|
res.send(source);
|
|
6831
7116
|
} catch {
|