@plank-cms/plank 0.21.3 → 0.23.0

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.
@@ -2948,7 +2948,20 @@ var ArraySubFieldSchema = z2.object({
2948
2948
  });
2949
2949
  var FieldSchema = z2.object({
2950
2950
  name: z2.string().regex(/^[a-z][a-z0-9_]*$/, "Field name must be lowercase with underscores"),
2951
- type: z2.enum(["string", "text", "richtext", "number", "boolean", "datetime", "media", "media-gallery", "relation", "uid", "array", "navigation"]),
2951
+ type: z2.enum([
2952
+ "string",
2953
+ "text",
2954
+ "richtext",
2955
+ "number",
2956
+ "boolean",
2957
+ "datetime",
2958
+ "media",
2959
+ "media-gallery",
2960
+ "relation",
2961
+ "uid",
2962
+ "array",
2963
+ "navigation"
2964
+ ]),
2952
2965
  required: z2.boolean().optional(),
2953
2966
  subtype: z2.enum(["integer", "float"]).optional(),
2954
2967
  relationType: z2.enum(["many-to-one", "one-to-one", "one-to-many", "many-to-many"]).optional(),
@@ -2974,6 +2987,9 @@ function isReservedSqlIdentifier(name) {
2974
2987
  }
2975
2988
  function findReservedIdentifierErrors(ct) {
2976
2989
  const errors = [];
2990
+ if (ct.tableName === "authors") {
2991
+ errors.push('Content type slug "authors" is reserved by the public API.');
2992
+ }
2977
2993
  if (isReservedSqlIdentifier(ct.tableName)) {
2978
2994
  errors.push(`Table name "${ct.tableName}" is reserved by SQL. Choose a different content type slug.`);
2979
2995
  }
@@ -3316,8 +3332,10 @@ var listEntries = async (req, res) => {
3316
3332
  const searchFields = rawSearchFields.filter((name) => ct.fields.some((f2) => f2.name === name && textLikeTypes.includes(f2.type)));
3317
3333
  const mainParams = [limit, offset];
3318
3334
  const countParams = [];
3335
+ const statusParams = [];
3319
3336
  const mainClauses = [];
3320
3337
  const countClauses = [];
3338
+ const statusClauses = [];
3321
3339
  const allowedStatuses = ["draft", "published", "scheduled", "pending", "in_review"];
3322
3340
  const statusFilter = req.query.status ? String(req.query.status) : "";
3323
3341
  if (statusFilter && allowedStatuses.includes(statusFilter)) {
@@ -3330,8 +3348,10 @@ var listEntries = async (req, res) => {
3330
3348
  const term = `%${search}%`;
3331
3349
  mainParams.push(term);
3332
3350
  countParams.push(term);
3351
+ statusParams.push(term);
3333
3352
  const mainIdx = mainParams.length;
3334
3353
  const countIdx = countParams.length;
3354
+ const statusIdx = statusParams.length;
3335
3355
  const searchMainConditions = searchFields.map((name) => {
3336
3356
  assertSafeIdentifier(name);
3337
3357
  return `e.${quoteIdentifier(name)}::text ILIKE $${mainIdx}`;
@@ -3339,12 +3359,17 @@ var listEntries = async (req, res) => {
3339
3359
  const searchCountConditions = searchFields.map((name) => {
3340
3360
  return `e.${quoteIdentifier(name)}::text ILIKE $${countIdx}`;
3341
3361
  });
3362
+ const searchStatusConditions = searchFields.map((name) => {
3363
+ return `e.${quoteIdentifier(name)}::text ILIKE $${statusIdx}`;
3364
+ });
3342
3365
  mainClauses.push(`(${searchMainConditions.join(" OR ")})`);
3343
3366
  countClauses.push(`(${searchCountConditions.join(" OR ")})`);
3367
+ statusClauses.push(`(${searchStatusConditions.join(" OR ")})`);
3344
3368
  }
3345
3369
  const mainWhereClause = mainClauses.length > 0 ? `WHERE ${mainClauses.join(" AND ")}` : "";
3346
3370
  const countWhereClause = countClauses.length > 0 ? `WHERE ${countClauses.join(" AND ")}` : "";
3347
- const [{ rows }, { rows: countRows }] = await Promise.all([
3371
+ const statusWhereClause = statusClauses.length > 0 ? `WHERE ${statusClauses.join(" AND ")}` : "";
3372
+ const [{ rows }, { rows: countRows }, { rows: statusRows }] = await Promise.all([
3348
3373
  pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
3349
3374
  ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3350
3375
  FROM ${quotedTableName} e
@@ -3353,7 +3378,11 @@ var listEntries = async (req, res) => {
3353
3378
  ${mainWhereClause}
3354
3379
  ORDER BY e.${quotedSortField} ${sortDir}
3355
3380
  LIMIT $1 OFFSET $2`, mainParams),
3356
- pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} e ${countWhereClause}`, countParams)
3381
+ pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} e ${countWhereClause}`, countParams),
3382
+ pool_default.query(`SELECT DISTINCT e.status
3383
+ FROM ${quotedTableName} e
3384
+ ${statusWhereClause}
3385
+ ORDER BY e.status`, statusParams)
3357
3386
  ]);
3358
3387
  const provider = await getProvider();
3359
3388
  function entryMatchesLocale(row, locale2) {
@@ -3392,7 +3421,13 @@ var listEntries = async (req, res) => {
3392
3421
  } catch {
3393
3422
  }
3394
3423
  }
3395
- res.json({ data, total, page, limit });
3424
+ res.json({
3425
+ data,
3426
+ total,
3427
+ page,
3428
+ limit,
3429
+ available_statuses: statusRows.map((row) => row.status).filter((value) => allowedStatuses.includes(value))
3430
+ });
3396
3431
  };
3397
3432
  async function loadManyToManyIds(entryId, tableName, fields) {
3398
3433
  const mmFields = fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many");
@@ -3406,6 +3441,28 @@ async function loadManyToManyIds(entryId, tableName, fields) {
3406
3441
  }));
3407
3442
  return result;
3408
3443
  }
3444
+ async function loadHydratedEntry(entryId, tableName, fields, locale, fallbacks = []) {
3445
+ const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
3446
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3447
+ FROM ${quoteIdentifier(tableName)} e
3448
+ LEFT JOIN plank_users u ON u.id = e.created_by
3449
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3450
+ WHERE e.id = $1`, [entryId]);
3451
+ if (!rows[0])
3452
+ return null;
3453
+ const mmIds = await loadManyToManyIds(entryId, tableName, fields);
3454
+ const provider = await getProvider();
3455
+ const resolved = resolveLocalizedRow(rows[0], { fields }, locale, fallbacks);
3456
+ const authorKey = resolved._author_avatar_url;
3457
+ if (authorKey && !authorKey.startsWith("http")) {
3458
+ resolved._author_avatar_url = await provider.getUrl(authorKey);
3459
+ }
3460
+ const editorKey = resolved._editor_avatar_url;
3461
+ if (editorKey && !editorKey.startsWith("http")) {
3462
+ resolved._editor_avatar_url = await provider.getUrl(editorKey);
3463
+ }
3464
+ return normalizeNavigationFields({ ...resolved, ...mmIds }, fields);
3465
+ }
3409
3466
  var getEntry = async (req, res) => {
3410
3467
  const ct = await findContentTypeBySlug(req.params.slug);
3411
3468
  if (!ct) {
@@ -3413,29 +3470,14 @@ var getEntry = async (req, res) => {
3413
3470
  return;
3414
3471
  }
3415
3472
  assertSafeIdentifier(ct.tableName);
3416
- const quotedTableName = quoteIdentifier(ct.tableName);
3417
- const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
3418
- ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3419
- FROM ${quotedTableName} e
3420
- LEFT JOIN plank_users u ON u.id = e.created_by
3421
- LEFT JOIN plank_users ed ON ed.id = e.editor_id
3422
- WHERE e.id = $1`, [req.params.id]);
3423
- if (!rows[0]) {
3473
+ const locale = req.query.locale ? String(req.query.locale) : void 0;
3474
+ const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
3475
+ const entry = await loadHydratedEntry(req.params.id, ct.tableName, ct.fields, locale, fallbacks);
3476
+ if (!entry) {
3424
3477
  res.status(404).json({ error: "Entry not found" });
3425
3478
  return;
3426
3479
  }
3427
- const locale = req.query.locale ? String(req.query.locale) : void 0;
3428
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
3429
- const mmIds = await loadManyToManyIds(req.params.id, ct.tableName, ct.fields);
3430
- const provider = await getProvider();
3431
- const resolved = resolveLocalizedRow(rows[0], ct, locale, fallbacks);
3432
- const key = resolved._author_avatar_url;
3433
- if (key && !key.startsWith("http"))
3434
- resolved._author_avatar_url = await provider.getUrl(key);
3435
- const editorKey = resolved._editor_avatar_url;
3436
- if (editorKey && !editorKey.startsWith("http"))
3437
- resolved._editor_avatar_url = await provider.getUrl(editorKey);
3438
- res.json(normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields));
3480
+ res.json(entry);
3439
3481
  };
3440
3482
  var createEntry = async (req, res) => {
3441
3483
  const ct = await findContentTypeBySlug(req.params.slug);
@@ -3688,7 +3730,7 @@ var patchEntryStatus = async (req, res) => {
3688
3730
  UPDATE ${quotedTableName} SET
3689
3731
  status = 'published',
3690
3732
  published_data = ${buildSnapshotExpr(ct.tableName)},
3691
- published_at = NOW(),
3733
+ published_at = COALESCE(published_at, NOW()),
3692
3734
  scheduled_for = NULL,
3693
3735
  review_rejected = FALSE,
3694
3736
  updated_at = NOW()
@@ -3762,7 +3804,7 @@ var patchEntryStatus = async (req, res) => {
3762
3804
  sql = `
3763
3805
  UPDATE ${quotedTableName} SET
3764
3806
  status = 'in_review',
3765
- editor_id = COALESCE($2, editor_id),
3807
+ editor_id = $2,
3766
3808
  review_locked_by_editor = $3,
3767
3809
  review_rejected = FALSE,
3768
3810
  updated_at = NOW()
@@ -3790,7 +3832,12 @@ var patchEntryStatus = async (req, res) => {
3790
3832
  res.status(404).json({ error: "Entry not found" });
3791
3833
  return;
3792
3834
  }
3793
- res.json(normalizeNavigationFields(rows[0], ct.fields));
3835
+ const entry = await loadHydratedEntry(req.params.id, ct.tableName, ct.fields);
3836
+ if (!entry) {
3837
+ res.status(404).json({ error: "Entry not found" });
3838
+ return;
3839
+ }
3840
+ res.json(entry);
3794
3841
  const webhookEvent = status === "published" ? "entry.published" : status === "draft" ? "entry.unpublished" : null;
3795
3842
  if (webhookEvent)
3796
3843
  triggerWebhooks(webhookEvent, { content_type: req.params.slug, entry_id: req.params.id });
@@ -3836,10 +3883,40 @@ var deleteEntry = async (req, res) => {
3836
3883
  import bcrypt2 from "bcryptjs";
3837
3884
  import { randomBytes as randomBytes6 } from "crypto";
3838
3885
  import { z as z4, flattenError as flattenError4 } from "zod";
3886
+
3887
+ // ../core/dist/lib/publicAuthorSlug.js
3888
+ function slugifySegment(value) {
3889
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
3890
+ }
3891
+ function baseSlugFromUser(input) {
3892
+ const fullName = [input.firstName, input.lastName].map((value) => value?.trim() ?? "").filter(Boolean).join(" ");
3893
+ const base = fullName || input.email.split("@")[0] || "author";
3894
+ return slugifySegment(base) || "author";
3895
+ }
3896
+ async function resolveUniquePublicAuthorSlug(input, excludeUserId) {
3897
+ const baseSlug = baseSlugFromUser(input);
3898
+ let slug = baseSlug;
3899
+ let suffix = 2;
3900
+ while (true) {
3901
+ const { rows } = await pool_default.query(`SELECT id
3902
+ FROM plank_users
3903
+ WHERE public_author_slug = $1
3904
+ AND ($2::text IS NULL OR id != $2)
3905
+ LIMIT 1`, [slug, excludeUserId ?? null]);
3906
+ if (!rows[0])
3907
+ return slug;
3908
+ slug = `${baseSlug}-${suffix}`;
3909
+ suffix += 1;
3910
+ }
3911
+ }
3912
+
3913
+ // ../core/dist/controllers/users.js
3839
3914
  var CreateUserSchema = z4.object({
3840
3915
  email: z4.email(),
3841
3916
  password: z4.string().min(8),
3842
3917
  roleId: z4.string().min(1),
3918
+ firstName: z4.string().trim().min(1).max(100),
3919
+ lastName: z4.string().trim().min(1).max(100),
3843
3920
  enabled: z4.boolean().optional()
3844
3921
  });
3845
3922
  var UpdateUserSchema = z4.object({
@@ -3908,7 +3985,7 @@ async function listUsers(_req, res) {
3908
3985
  res.json(rows);
3909
3986
  }
3910
3987
  async function getMe(req, res) {
3911
- const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, u.first_name, u.last_name, u.avatar_url,
3988
+ const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, u.first_name, u.last_name, u.public_author_slug, u.avatar_url,
3912
3989
  u.job_title, u.organization, u.country, u.two_factor_enabled, u.enabled, u.created_at,
3913
3990
  r.name AS role_name, r.permissions
3914
3991
  FROM plank_users u
@@ -3987,7 +4064,9 @@ async function disableTwoFactor(req, res) {
3987
4064
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
3988
4065
  return;
3989
4066
  }
3990
- const { rows } = await pool_default.query("SELECT two_factor_enabled, two_factor_secret, password FROM plank_users WHERE id = $1", [req.user.id]);
4067
+ const { rows } = await pool_default.query("SELECT two_factor_enabled, two_factor_secret, password FROM plank_users WHERE id = $1", [
4068
+ req.user.id
4069
+ ]);
3991
4070
  const user = rows[0];
3992
4071
  if (!user) {
3993
4072
  res.status(404).json({ error: "User not found" });
@@ -4025,7 +4104,9 @@ async function regenerateBackupCodes(req, res) {
4025
4104
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
4026
4105
  return;
4027
4106
  }
4028
- const { rows } = await pool_default.query("SELECT two_factor_enabled, two_factor_secret, password FROM plank_users WHERE id = $1", [req.user.id]);
4107
+ const { rows } = await pool_default.query("SELECT two_factor_enabled, two_factor_secret, password FROM plank_users WHERE id = $1", [
4108
+ req.user.id
4109
+ ]);
4029
4110
  const user = rows[0];
4030
4111
  if (!user || !user.two_factor_enabled || !user.two_factor_secret) {
4031
4112
  res.status(400).json({ error: "2FA is not enabled" });
@@ -4036,7 +4117,10 @@ async function regenerateBackupCodes(req, res) {
4036
4117
  res.status(401).json({ error: "Current password is incorrect" });
4037
4118
  return;
4038
4119
  }
4039
- const totpResult = d3({ token: parsed.data.code, secret: decrypt(user.two_factor_secret) });
4120
+ const totpResult = d3({
4121
+ token: parsed.data.code,
4122
+ secret: decrypt(user.two_factor_secret)
4123
+ });
4040
4124
  if (!totpResult.valid) {
4041
4125
  res.status(401).json({ error: "Invalid verification code" });
4042
4126
  return;
@@ -4051,15 +4135,36 @@ async function updateMe(req, res) {
4051
4135
  return;
4052
4136
  }
4053
4137
  const { firstName, lastName, jobTitle, organization, country } = parsed.data;
4138
+ const { rows: currentRows } = await pool_default.query("SELECT email, first_name, last_name FROM plank_users WHERE id = $1", [req.user.id]);
4139
+ if (!currentRows[0]) {
4140
+ res.status(404).json({ error: "User not found" });
4141
+ return;
4142
+ }
4143
+ const nextFirstName = firstName ?? currentRows[0].first_name;
4144
+ const nextLastName = lastName ?? currentRows[0].last_name;
4145
+ const publicAuthorSlug = await resolveUniquePublicAuthorSlug({
4146
+ email: currentRows[0].email,
4147
+ firstName: nextFirstName,
4148
+ lastName: nextLastName
4149
+ }, req.user.id);
4054
4150
  const { rows } = await pool_default.query(`UPDATE plank_users
4055
4151
  SET first_name = COALESCE($1, first_name),
4056
4152
  last_name = COALESCE($2, last_name),
4057
4153
  job_title = COALESCE($3, job_title),
4058
4154
  organization = COALESCE($4, organization),
4059
- country = COALESCE($5, country)
4060
- WHERE id = $6
4061
- RETURNING id, email, role_id, first_name, last_name, avatar_url,
4062
- job_title, organization, country, created_at`, [firstName ?? null, lastName ?? null, jobTitle ?? null, organization ?? null, country ?? null, req.user.id]);
4155
+ country = COALESCE($5, country),
4156
+ public_author_slug = $6
4157
+ WHERE id = $7
4158
+ RETURNING id, email, role_id, first_name, last_name, public_author_slug, avatar_url,
4159
+ job_title, organization, country, created_at`, [
4160
+ firstName ?? null,
4161
+ lastName ?? null,
4162
+ jobTitle ?? null,
4163
+ organization ?? null,
4164
+ country ?? null,
4165
+ publicAuthorSlug,
4166
+ req.user.id
4167
+ ]);
4063
4168
  if (!rows[0]) {
4064
4169
  res.status(404).json({ error: "User not found" });
4065
4170
  return;
@@ -4105,7 +4210,9 @@ async function confirmAvatar(req, res) {
4105
4210
  res.json({ avatarUrl, user: await resolveAvatarUrl(rows[0]) });
4106
4211
  }
4107
4212
  async function deleteAvatar(req, res) {
4108
- const { rows } = await pool_default.query("SELECT avatar_url FROM plank_users WHERE id = $1", [req.user.id]);
4213
+ const { rows } = await pool_default.query("SELECT avatar_url FROM plank_users WHERE id = $1", [
4214
+ req.user.id
4215
+ ]);
4109
4216
  const current = rows[0]?.avatar_url;
4110
4217
  if (current && !current.startsWith("http")) {
4111
4218
  const provider = await getProvider();
@@ -4141,7 +4248,7 @@ async function createUser(req, res) {
4141
4248
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
4142
4249
  return;
4143
4250
  }
4144
- const { email, password, roleId, enabled } = parsed.data;
4251
+ const { email, password, roleId, firstName, lastName, enabled } = parsed.data;
4145
4252
  const requesterRoleName = await roleNameById(req.user.roleId);
4146
4253
  const targetRoleName = await roleNameById(roleId);
4147
4254
  if (targetRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
@@ -4153,8 +4260,19 @@ async function createUser(req, res) {
4153
4260
  const nextEnabled = editorialMode || !isEditorialExclusiveRole ? enabled ?? true : false;
4154
4261
  const hashed = await bcrypt2.hash(password, 12);
4155
4262
  const id = createId();
4156
- await pool_default.query("INSERT INTO plank_users (id, email, password, role_id, enabled) VALUES ($1, $2, $3, $4, $5)", [id, email, hashed, roleId, nextEnabled]);
4157
- res.status(201).json({ id, email, roleId, enabled: nextEnabled });
4263
+ const publicAuthorSlug = await resolveUniquePublicAuthorSlug({ email, firstName, lastName });
4264
+ await pool_default.query(`INSERT INTO plank_users
4265
+ (id, email, password, role_id, first_name, last_name, public_author_slug, enabled)
4266
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id, email, hashed, roleId, firstName, lastName, publicAuthorSlug, nextEnabled]);
4267
+ res.status(201).json({
4268
+ id,
4269
+ email,
4270
+ roleId,
4271
+ first_name: firstName,
4272
+ last_name: lastName,
4273
+ public_author_slug: publicAuthorSlug,
4274
+ enabled: nextEnabled
4275
+ });
4158
4276
  }
4159
4277
  async function updateUser(req, res) {
4160
4278
  const parsed = UpdateUserSchema.safeParse(req.body);
@@ -4186,18 +4304,41 @@ async function updateUser(req, res) {
4186
4304
  resolvedEnabled = false;
4187
4305
  }
4188
4306
  }
4307
+ const targetUserId = String(req.params.id);
4308
+ const { rows: currentRows } = await pool_default.query("SELECT email, first_name, last_name FROM plank_users WHERE id = $1", [targetUserId]);
4309
+ if (!currentRows[0]) {
4310
+ res.status(404).json({ error: "User not found" });
4311
+ return;
4312
+ }
4313
+ const nextFirstName = firstName ?? currentRows[0].first_name;
4314
+ const nextLastName = lastName ?? currentRows[0].last_name;
4315
+ const nextEmail = email ?? currentRows[0].email;
4316
+ const publicAuthorSlug = await resolveUniquePublicAuthorSlug({
4317
+ email: nextEmail,
4318
+ firstName: nextFirstName,
4319
+ lastName: nextLastName
4320
+ }, targetUserId);
4189
4321
  const { rows } = await pool_default.query(`UPDATE plank_users
4190
4322
  SET email = COALESCE($1, email),
4191
4323
  role_id = COALESCE($2, role_id),
4192
4324
  first_name = COALESCE($3, first_name),
4193
4325
  last_name = COALESCE($4, last_name),
4194
4326
  enabled = COALESCE($5, enabled),
4327
+ public_author_slug = $6,
4195
4328
  session_version = CASE
4196
4329
  WHEN $5 IS NOT NULL AND $5 = FALSE AND enabled = TRUE THEN session_version + 1
4197
4330
  ELSE session_version
4198
4331
  END
4199
- WHERE id = $6
4200
- RETURNING id, email, role_id, first_name, last_name, enabled, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, resolvedEnabled ?? null, req.params.id]);
4332
+ WHERE id = $7
4333
+ RETURNING id, email, role_id, first_name, last_name, public_author_slug, enabled, created_at`, [
4334
+ email ?? null,
4335
+ roleId ?? null,
4336
+ firstName ?? null,
4337
+ lastName ?? null,
4338
+ resolvedEnabled ?? null,
4339
+ publicAuthorSlug,
4340
+ req.params.id
4341
+ ]);
4201
4342
  if (!rows[0]) {
4202
4343
  res.status(404).json({ error: "User not found" });
4203
4344
  return;
@@ -4638,6 +4779,14 @@ async function getAppModes(req, res) {
4638
4779
  const modes = req.appModes ?? await resolveAppModes();
4639
4780
  res.json(modes);
4640
4781
  }
4782
+ async function getClientSettings(_req, res) {
4783
+ const settings = await getSettings("general");
4784
+ res.json({
4785
+ timezone: settings.timezone ?? "UTC",
4786
+ locales: settings.locales ?? '["en"]',
4787
+ default_locale: settings.default_locale ?? "en"
4788
+ });
4789
+ }
4641
4790
  async function getEditorialMode(_req, res) {
4642
4791
  const { editorial: enabled } = await resolveAppModes();
4643
4792
  res.json({ enabled });
@@ -4721,6 +4870,7 @@ router2.get("/media/:id/url", authorize("media:read"), getMediaUrl);
4721
4870
  router2.patch("/media/:id", authorize("media:write"), updateMedia);
4722
4871
  router2.delete("/media/:id", authorize("media:delete"), deleteMedia);
4723
4872
  router2.get("/modes", getAppModes);
4873
+ router2.get("/client-settings", getClientSettings);
4724
4874
  router2.get("/editorial-mode", getEditorialMode);
4725
4875
  router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
4726
4876
  router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
@@ -5098,6 +5248,23 @@ async function resolveAuthorAvatars(entries) {
5098
5248
  }
5099
5249
  }));
5100
5250
  }
5251
+ async function resolvePublicAuthorAvatar(author) {
5252
+ if (!author.avatar_url || author.avatar_url.startsWith("http"))
5253
+ return author;
5254
+ const provider = await getProvider();
5255
+ return { ...author, avatar_url: await provider.getUrl(author.avatar_url) };
5256
+ }
5257
+ function serializePublicAuthor(row) {
5258
+ return {
5259
+ first_name: row.first_name,
5260
+ last_name: row.last_name,
5261
+ avatar_url: row.avatar_url,
5262
+ job_title: row.job_title,
5263
+ organization: row.organization,
5264
+ country: row.country,
5265
+ slug: row.public_author_slug
5266
+ };
5267
+ }
5101
5268
  function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
5102
5269
  const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, _editor_first_name, _editor_last_name, _editor_avatar_url, _editor_job_title, _editor_organization, _editor_country, ...rest } = row;
5103
5270
  const source = statusParam === "published" && published_data ? published_data : rest;
@@ -5148,7 +5315,8 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
5148
5315
  avatar_url: _author_avatar_url ?? null,
5149
5316
  job_title: _author_job_title ?? null,
5150
5317
  organization: _author_organization ?? null,
5151
- country: _author_country ?? null
5318
+ country: _author_country ?? null,
5319
+ slug: row._author_slug ?? null
5152
5320
  } : null;
5153
5321
  out.editor = _editor_first_name || _editor_last_name ? {
5154
5322
  first_name: _editor_first_name ?? null,
@@ -5156,7 +5324,8 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
5156
5324
  avatar_url: _editor_avatar_url ?? null,
5157
5325
  job_title: _editor_job_title ?? null,
5158
5326
  organization: _editor_organization ?? null,
5159
- country: _editor_country ?? null
5327
+ country: _editor_country ?? null,
5328
+ slug: row._editor_slug ?? null
5160
5329
  } : null;
5161
5330
  return out;
5162
5331
  }
@@ -5178,8 +5347,8 @@ var listPublicEntries = async (req, res) => {
5178
5347
  const fallbacks2 = req.query.fallback ? String(req.query.fallback).split(",") : [];
5179
5348
  const statusClause = statusParam2 === "published" || statusParam2 === "draft" ? `WHERE e.status = $1` : "";
5180
5349
  const values = statusClause ? [statusParam2] : [];
5181
- const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5182
- ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5350
+ const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.public_author_slug AS _author_slug, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5351
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.public_author_slug AS _editor_slug, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5183
5352
  FROM ${ct.tableName} e
5184
5353
  LEFT JOIN plank_users u ON u.id = e.created_by
5185
5354
  LEFT JOIN plank_users ed ON ed.id = e.editor_id
@@ -5217,6 +5386,11 @@ var listPublicEntries = async (req, res) => {
5217
5386
  filterClauses.push(`e.status = $${filterValues.length + 1}`);
5218
5387
  filterValues.push(statusParam);
5219
5388
  }
5389
+ const authorSlug = typeof req.query.author === "string" ? req.query.author.trim() : "";
5390
+ if (authorSlug) {
5391
+ filterClauses.push(`u.public_author_slug = $${filterValues.length + 1}`);
5392
+ filterValues.push(authorSlug);
5393
+ }
5220
5394
  const rawSort = String(req.query.sort ?? "created_at");
5221
5395
  const sortField = knownFields.has(rawSort) || systemSortFields.has(rawSort) ? rawSort : "created_at";
5222
5396
  assertSafeIdentifier(sortField);
@@ -5239,8 +5413,8 @@ var listPublicEntries = async (req, res) => {
5239
5413
  filterClauses.push(parsedFilter.operator === "nin" ? `NOT (e.${fieldName} = ANY($${filterValues.length + 1}))` : `e.${fieldName} = ANY($${filterValues.length + 1})`);
5240
5414
  filterValues.push(coercedValues);
5241
5415
  }
5242
- for (const [key, value] of Object.entries(req.query)) {
5243
- if (key === "page" || key === "limit" || key === "status" || key === "sort" || key === "order" || key === "locale" || key === "fallback" || key === "fields" || key === "select" || key === "exclude" || key === "filters" || key.startsWith("filters["))
5416
+ for (const [key] of Object.entries(req.query)) {
5417
+ if (key === "page" || key === "limit" || key === "status" || key === "sort" || key === "order" || key === "locale" || key === "fallback" || key === "fields" || key === "select" || key === "exclude" || key === "author" || key === "filters" || key.startsWith("filters["))
5244
5418
  continue;
5245
5419
  if (knownFields.has(key))
5246
5420
  continue;
@@ -5251,13 +5425,16 @@ var listPublicEntries = async (req, res) => {
5251
5425
  const limitParam = filterValues.length + 1;
5252
5426
  const offsetParam = filterValues.length + 2;
5253
5427
  const [{ rows }, { rows: countRows }] = await Promise.all([
5254
- pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5255
- ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5428
+ pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.public_author_slug AS _author_slug, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5429
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.public_author_slug AS _editor_slug, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5256
5430
  FROM ${ct.tableName} e
5257
5431
  LEFT JOIN plank_users u ON u.id = e.created_by
5258
5432
  LEFT JOIN plank_users ed ON ed.id = e.editor_id
5259
5433
  ${where} ORDER BY e.${sortField} ${sortDir} LIMIT $${limitParam} OFFSET $${offsetParam}`, [...filterValues, limit, offset]),
5260
- pool_default.query(`SELECT COUNT(*) as count FROM ${ct.tableName} e ${where}`, filterValues)
5434
+ pool_default.query(`SELECT COUNT(*) as count
5435
+ FROM ${ct.tableName} e
5436
+ LEFT JOIN plank_users u ON u.id = e.created_by
5437
+ ${where}`, filterValues)
5261
5438
  ]);
5262
5439
  const data = rows.map((row) => serializeEntry(row, ct, statusParam, locale, fallbacks));
5263
5440
  await Promise.all([
@@ -5287,8 +5464,8 @@ var getPublicEntry = async (req, res) => {
5287
5464
  const statusParam = String(req.query.status ?? "published");
5288
5465
  const statusClause = statusParam === "published" || statusParam === "draft" ? ` AND e.status = $2` : "";
5289
5466
  const values = statusClause ? [req.params.id, statusParam] : [req.params.id];
5290
- const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5291
- ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5467
+ const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.public_author_slug AS _author_slug, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
5468
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.public_author_slug AS _editor_slug, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
5292
5469
  FROM ${ct.tableName} e
5293
5470
  LEFT JOIN plank_users u ON u.id = e.created_by
5294
5471
  LEFT JOIN plank_users ed ON ed.id = e.editor_id
@@ -5307,10 +5484,22 @@ var getPublicEntry = async (req, res) => {
5307
5484
  ]);
5308
5485
  res.json(selectEntryFields(entry, ct, selection));
5309
5486
  };
5487
+ var getPublicAuthor = async (req, res) => {
5488
+ const { rows } = await pool_default.query(`SELECT public_author_slug, first_name, last_name, avatar_url, job_title, organization, country
5489
+ FROM plank_users
5490
+ WHERE public_author_slug = $1
5491
+ LIMIT 1`, [req.params.slug]);
5492
+ if (!rows[0]) {
5493
+ res.status(404).json({ error: "Not found" });
5494
+ return;
5495
+ }
5496
+ res.json(await resolvePublicAuthorAvatar(serializePublicAuthor(rows[0])));
5497
+ };
5310
5498
 
5311
5499
  // ../core/dist/routes/public.js
5312
5500
  var router3 = Router3();
5313
5501
  router3.use(apiToken);
5502
+ router3.get("/authors/:slug", getPublicAuthor);
5314
5503
  router3.get("/:slug", listPublicEntries);
5315
5504
  router3.get("/:slug/:id", getPublicEntry);
5316
5505
  var public_default = router3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plank-cms/plank",
3
- "version": "0.21.3",
3
+ "version": "0.23.0",
4
4
  "description": "Self-hosted headless CMS. Deploy in minutes on your own infrastructure.",
5
5
  "type": "module",
6
6
  "files": [
@@ -55,9 +55,9 @@
55
55
  "devDependencies": {
56
56
  "@types/fs-extra": "^11.0.4",
57
57
  "tsup": "^8.5.0",
58
- "@plank-cms/core": "0.21.3",
59
- "@plank-cms/db": "0.21.3",
60
- "@plank-cms/schema": "0.21.3"
58
+ "@plank-cms/core": "0.23.0",
59
+ "@plank-cms/schema": "0.23.0",
60
+ "@plank-cms/db": "0.23.0"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsup",