@plank-cms/plank 0.12.1 → 0.14.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.
@@ -1,2742 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // ../db/dist/pool.js
4
- import pg from "pg";
5
- pg.types.setTypeParser(1114, (val) => val ? /* @__PURE__ */ new Date(val.replace(" ", "T") + "Z") : null);
6
- pg.types.setTypeParser(1700, (val) => val !== null ? parseFloat(val) : null);
7
- var pool = new pg.Pool({
8
- connectionString: process.env.PLANK_DATABASE_URL,
9
- max: 10,
10
- idleTimeoutMillis: 3e4,
11
- connectionTimeoutMillis: 2e3
12
- });
13
- pool.on("error", (err) => {
14
- console.error("[plank/db] Unexpected pool error:", err.message);
15
- process.exit(1);
16
- });
17
- var pool_default = pool;
18
-
19
- // ../db/dist/migrate.js
20
- import { readdir, readFile } from "fs/promises";
21
- import { join, dirname } from "path";
22
- import { fileURLToPath } from "url";
23
-
24
- // ../db/dist/id.js
25
- import { randomUUID } from "crypto";
26
- var createId = () => randomUUID();
27
-
28
- // ../db/dist/defaults.js
29
- var DEFAULT_ROLE_PERMISSIONS = {
30
- "Super Admin": ["*"],
31
- "Admin": [
32
- "content-types:read",
33
- "content-types:write",
34
- "entries:read",
35
- "entries:write",
36
- "entries:delete",
37
- "media:read",
38
- "media:write",
39
- "media:delete",
40
- "settings:overview:read",
41
- "settings:users:read",
42
- "settings:users:write",
43
- "settings:users:delete",
44
- "settings:webhooks:read",
45
- "settings:webhooks:write",
46
- "settings:webhooks:delete"
47
- ],
48
- "User": [
49
- "content-types:read",
50
- "entries:read",
51
- "entries:write",
52
- "media:read",
53
- "media:write"
54
- ]
55
- };
56
-
57
- // ../db/dist/migrate.js
58
- var MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "migrations");
59
- async function ensureMigrationsTable(client) {
60
- await client.query(`
61
- CREATE TABLE IF NOT EXISTS plank_migrations (
62
- id TEXT PRIMARY KEY,
63
- filename VARCHAR(255) NOT NULL UNIQUE,
64
- applied_at TIMESTAMP NOT NULL DEFAULT NOW()
65
- )
66
- `);
67
- }
68
- async function appliedMigrations(client) {
69
- const { rows } = await client.query("SELECT filename FROM plank_migrations ORDER BY filename");
70
- return new Set(rows.map((r) => r.filename));
71
- }
72
- async function seedDefaultRoles(client) {
73
- const { rows } = await client.query("SELECT COUNT(*) as count FROM plank_roles");
74
- if (parseInt(rows[0].count) > 0)
75
- return;
76
- for (const [name, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
77
- await client.query("INSERT INTO plank_roles (id, name, permissions) VALUES ($1, $2, $3)", [createId(), name, JSON.stringify(permissions)]);
78
- }
79
- console.log("[plank/db] Seeded default roles.");
80
- }
81
- async function migrate() {
82
- const client = await pool_default.connect();
83
- try {
84
- await client.query("BEGIN");
85
- await ensureMigrationsTable(client);
86
- const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
87
- const applied = await appliedMigrations(client);
88
- for (const file of files) {
89
- if (applied.has(file))
90
- continue;
91
- const sql = await readFile(join(MIGRATIONS_DIR, file), "utf8");
92
- await client.query(sql);
93
- await client.query("INSERT INTO plank_migrations (id, filename) VALUES ($1, $2)", [createId(), file]);
94
- console.log(`[plank/db] Applied migration: ${file}`);
95
- }
96
- await seedDefaultRoles(client);
97
- await client.query("COMMIT");
98
- console.log("[plank/db] Migrations up to date.");
99
- } catch (err) {
100
- await client.query("ROLLBACK");
101
- throw err;
102
- } finally {
103
- client.release();
104
- }
105
- }
106
-
107
- // ../schema/dist/types.js
108
- var ValidationError = class extends Error {
109
- errors;
110
- constructor(errors) {
111
- super(errors.join(", "));
112
- this.errors = errors;
113
- this.name = "ValidationError";
114
- }
115
- };
116
- var SchemaError = class extends Error {
117
- constructor(message) {
118
- super(message);
119
- this.name = "SchemaError";
120
- }
121
- };
122
-
123
- // ../schema/dist/fieldTypes.js
124
- function quoteIdentifier(name) {
125
- return `"${name.replace(/"/g, '""')}"`;
126
- }
127
- function toPostgresType(field) {
128
- switch (field.type) {
129
- case "string":
130
- return "VARCHAR(255)";
131
- case "text":
132
- case "richtext":
133
- return "TEXT";
134
- case "number":
135
- return field.subtype === "float" ? "NUMERIC" : "INTEGER";
136
- case "boolean":
137
- return "BOOLEAN";
138
- case "datetime":
139
- return "TIMESTAMP";
140
- case "media":
141
- return "TEXT";
142
- case "media-gallery":
143
- return "JSONB";
144
- case "relation": {
145
- const relType = field.relationType ?? "many-to-one";
146
- if (relType === "one-to-many" || relType === "many-to-many") {
147
- throw new SchemaError(`"${relType}" relation does not produce a column`);
148
- }
149
- if (!field.relatedTable)
150
- return "TEXT";
151
- const relatedTable = quoteIdentifier(field.relatedTable);
152
- const unique = relType === "one-to-one" ? " UNIQUE" : "";
153
- return `TEXT${unique} REFERENCES ${relatedTable}(id) ON DELETE SET NULL`;
154
- }
155
- case "uid":
156
- return "VARCHAR(255) UNIQUE";
157
- case "array":
158
- return "JSONB";
159
- default:
160
- throw new SchemaError(`Unknown field type: "${field.type}"`);
161
- }
162
- }
163
- function isVirtualRelation(field) {
164
- if (field.type !== "relation")
165
- return false;
166
- const rt = field.relationType ?? "many-to-one";
167
- return rt === "one-to-many" || rt === "many-to-many";
168
- }
169
- function hasRelationColumn(field) {
170
- if (field.type !== "relation")
171
- return false;
172
- const rt = field.relationType ?? "many-to-one";
173
- return rt === "many-to-one" || rt === "one-to-one";
174
- }
175
- function assertSafeIdentifier(name) {
176
- if (!/^[a-z][a-z0-9_]*$/.test(name)) {
177
- throw new SchemaError(`Invalid identifier "${name}". Use only lowercase letters, digits, and underscores.`);
178
- }
179
- }
180
-
181
- // ../schema/dist/store.js
182
- function rowToContentType(row) {
183
- return {
184
- id: row.id,
185
- name: row.name,
186
- slug: row.slug,
187
- kind: row.kind,
188
- tableName: row.table_name,
189
- fields: row.fields,
190
- isDefault: row.is_default,
191
- createdAt: row.created_at,
192
- updatedAt: row.updated_at
193
- };
194
- }
195
- async function findAllContentTypes() {
196
- const { rows } = await pool_default.query("SELECT * FROM plank_content_types ORDER BY name");
197
- return rows.map(rowToContentType);
198
- }
199
- async function findContentTypeBySlug(slug) {
200
- const { rows } = await pool_default.query("SELECT * FROM plank_content_types WHERE slug = $1", [slug]);
201
- return rows[0] ? rowToContentType(rows[0]) : null;
202
- }
203
- async function saveContentType(contentType) {
204
- const { rows } = await pool_default.query(`INSERT INTO plank_content_types (id, name, slug, kind, table_name, fields)
205
- VALUES ($1, $2, $3, $4, $5, $6)
206
- RETURNING *`, [createId(), contentType.name, contentType.slug, contentType.kind ?? "collection", contentType.tableName, JSON.stringify(contentType.fields)]);
207
- return rowToContentType(rows[0]);
208
- }
209
- async function updateContentType(slug, contentType) {
210
- const { rows } = await pool_default.query(`UPDATE plank_content_types
211
- SET name = $1, fields = $2, updated_at = NOW()
212
- WHERE slug = $3
213
- RETURNING *`, [contentType.name, JSON.stringify(contentType.fields), slug]);
214
- return rowToContentType(rows[0]);
215
- }
216
- async function setDefaultContentType(slug) {
217
- const client = await pool_default.connect();
218
- try {
219
- await client.query("BEGIN");
220
- await client.query("UPDATE plank_content_types SET is_default = false");
221
- const { rows } = await client.query("UPDATE plank_content_types SET is_default = true WHERE slug = $1 RETURNING *", [slug]);
222
- if (!rows[0])
223
- throw new Error(`Content type "${slug}" not found`);
224
- await client.query("COMMIT");
225
- return rowToContentType(rows[0]);
226
- } catch (err) {
227
- await client.query("ROLLBACK");
228
- throw err;
229
- } finally {
230
- client.release();
231
- }
232
- }
233
- async function deleteContentType(slug) {
234
- await pool_default.query("DELETE FROM plank_content_types WHERE slug = $1", [slug]);
235
- }
236
-
237
- // ../schema/dist/tableBuilder.js
238
- function buildColumnDef(field) {
239
- if (isVirtualRelation(field))
240
- return null;
241
- assertSafeIdentifier(field.name);
242
- const pgType = toPostgresType(field);
243
- const notNull = field.required ? " NOT NULL" : "";
244
- return `${quoteIdentifier(field.name)} ${pgType}${notNull}`;
245
- }
246
- function junctionTableName(sourceTable, fieldName) {
247
- return `_rel_${sourceTable}_${fieldName}`;
248
- }
249
- function buildJunctionTableSQL(sourceTable, fieldName, targetTable) {
250
- const jt = junctionTableName(sourceTable, fieldName);
251
- assertSafeIdentifier(targetTable);
252
- const quotedJt = quoteIdentifier(jt);
253
- const quotedSourceTable = quoteIdentifier(sourceTable);
254
- const quotedTargetTable = quoteIdentifier(targetTable);
255
- return [
256
- `CREATE TABLE IF NOT EXISTS ${quotedJt} (`,
257
- ` source_id TEXT NOT NULL REFERENCES ${quotedSourceTable}(id) ON DELETE CASCADE,`,
258
- ` target_id TEXT NOT NULL REFERENCES ${quotedTargetTable}(id) ON DELETE CASCADE,`,
259
- ` PRIMARY KEY (source_id, target_id)`,
260
- `)`
261
- ].join("\n");
262
- }
263
- function relationSignature(field) {
264
- if (field.type !== "relation")
265
- return "";
266
- const rt = field.relationType ?? "many-to-one";
267
- return `${rt}:${field.relatedTable ?? ""}`;
268
- }
269
- async function createTable(contentType) {
270
- assertSafeIdentifier(contentType.tableName);
271
- const quotedTableName = quoteIdentifier(contentType.tableName);
272
- const columnFields = contentType.fields.filter((f) => !isVirtualRelation(f));
273
- const columns = columnFields.map(buildColumnDef).filter(Boolean);
274
- const sql = [
275
- `CREATE TABLE IF NOT EXISTS ${quotedTableName} (`,
276
- ` id TEXT PRIMARY KEY,`,
277
- ...columns.map((col) => ` ${col},`),
278
- ` localized JSONB,`,
279
- ` status VARCHAR(20) NOT NULL DEFAULT 'draft',`,
280
- ` published_data JSONB,`,
281
- ` published_at TIMESTAMP,`,
282
- ` scheduled_for TIMESTAMP,`,
283
- ` created_by TEXT REFERENCES plank_users(id) ON DELETE SET NULL,`,
284
- ` created_at TIMESTAMP NOT NULL DEFAULT NOW(),`,
285
- ` updated_at TIMESTAMP NOT NULL DEFAULT NOW()`,
286
- `)`
287
- ].join("\n");
288
- await pool_default.query(sql);
289
- try {
290
- await pool_default.query(`CREATE INDEX IF NOT EXISTS idx_${contentType.tableName}_localized_gin ON ${quotedTableName} USING gin (localized)`);
291
- } catch (err) {
292
- }
293
- for (const field of contentType.fields) {
294
- if (field.type === "relation" && (field.relationType ?? "many-to-one") === "many-to-many" && field.relatedTable) {
295
- await pool_default.query(buildJunctionTableSQL(contentType.tableName, field.name, field.relatedTable));
296
- }
297
- }
298
- }
299
- async function syncTable(next, prev) {
300
- assertSafeIdentifier(next.tableName);
301
- const quotedTableName = quoteIdentifier(next.tableName);
302
- const prevFields = new Map(prev.fields.map((f) => [f.name, f]));
303
- const nextFields = new Map(next.fields.map((f) => [f.name, f]));
304
- const statements = [];
305
- const junctionOps = [];
306
- for (const [name, field] of nextFields) {
307
- if (!prevFields.has(name)) {
308
- if (isVirtualRelation(field))
309
- continue;
310
- assertSafeIdentifier(name);
311
- const colDef = buildColumnDef(field);
312
- if (colDef)
313
- statements.push(`ALTER TABLE ${quotedTableName} ADD COLUMN IF NOT EXISTS ${colDef}`);
314
- if (field.type === "relation" && (field.relationType ?? "many-to-one") === "many-to-many" && field.relatedTable) {
315
- const sql = buildJunctionTableSQL(next.tableName, name, field.relatedTable);
316
- junctionOps.push(() => pool_default.query(sql));
317
- }
318
- }
319
- }
320
- for (const [name] of prevFields) {
321
- if (!nextFields.has(name)) {
322
- const prevField = prevFields.get(name);
323
- assertSafeIdentifier(name);
324
- if (!isVirtualRelation(prevField)) {
325
- statements.push(`ALTER TABLE ${quotedTableName} DROP COLUMN ${quoteIdentifier(name)}`);
326
- }
327
- if (prevField.type === "relation" && (prevField.relationType ?? "many-to-one") === "many-to-many") {
328
- const jt = junctionTableName(next.tableName, name);
329
- junctionOps.push(() => pool_default.query(`DROP TABLE IF EXISTS ${jt}`));
330
- }
331
- }
332
- }
333
- for (const [name, nextField] of nextFields) {
334
- const prevField = prevFields.get(name);
335
- if (!prevField)
336
- continue;
337
- if (nextField.type === "relation" || prevField.type === "relation") {
338
- const prevSig = relationSignature(prevField);
339
- const nextSig = relationSignature(nextField);
340
- if (prevSig === nextSig)
341
- continue;
342
- assertSafeIdentifier(name);
343
- if (prevField.type === "relation" && (prevField.relationType ?? "many-to-one") === "many-to-many") {
344
- const jt = junctionTableName(next.tableName, name);
345
- junctionOps.push(() => pool_default.query(`DROP TABLE IF EXISTS ${jt}`));
346
- }
347
- if (hasRelationColumn(prevField)) {
348
- statements.push(`ALTER TABLE ${quotedTableName} DROP COLUMN IF EXISTS ${quoteIdentifier(name)}`);
349
- }
350
- if (hasRelationColumn(nextField)) {
351
- const colDef = buildColumnDef(nextField);
352
- if (colDef)
353
- statements.push(`ALTER TABLE ${quotedTableName} ADD COLUMN IF NOT EXISTS ${colDef}`);
354
- }
355
- if (nextField.type === "relation" && (nextField.relationType ?? "many-to-one") === "many-to-many" && nextField.relatedTable) {
356
- const sql = buildJunctionTableSQL(next.tableName, name, nextField.relatedTable);
357
- junctionOps.push(() => pool_default.query(sql));
358
- }
359
- continue;
360
- }
361
- if (toPostgresType(prevField) !== toPostgresType(nextField)) {
362
- assertSafeIdentifier(name);
363
- const pgType = toPostgresType(nextField);
364
- statements.push(`ALTER TABLE ${quotedTableName} ALTER COLUMN ${quoteIdentifier(name)} TYPE ${pgType} USING ${quoteIdentifier(name)}::text::${pgType}`);
365
- }
366
- }
367
- if (statements.length > 0) {
368
- const client = await pool_default.connect();
369
- try {
370
- await client.query("BEGIN");
371
- for (const stmt of statements) {
372
- await client.query(stmt);
373
- }
374
- await client.query("COMMIT");
375
- } catch (err) {
376
- await client.query("ROLLBACK");
377
- throw err;
378
- } finally {
379
- client.release();
380
- }
381
- for (const op of junctionOps) {
382
- await op();
383
- }
384
- }
385
- }
386
- async function syncAllTables() {
387
- const contentTypes = await findAllContentTypes();
388
- for (const ct of contentTypes) {
389
- assertSafeIdentifier(ct.tableName);
390
- const quotedTableName = quoteIdentifier(ct.tableName);
391
- const { rows } = await pool_default.query(`SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`, [ct.tableName]);
392
- const existingColumns = new Set(rows.map((r) => r.column_name));
393
- if (!existingColumns.has("localized")) {
394
- try {
395
- await pool_default.query(`ALTER TABLE ${quotedTableName} ADD COLUMN IF NOT EXISTS localized JSONB`);
396
- existingColumns.add("localized");
397
- try {
398
- await pool_default.query(`CREATE INDEX IF NOT EXISTS idx_${ct.tableName}_localized_gin ON ${quotedTableName} USING gin (localized)`);
399
- } catch (err) {
400
- }
401
- console.log(`[plank] Added missing column "localized" to table "${ct.tableName}"`);
402
- } catch (err) {
403
- }
404
- }
405
- for (const field of ct.fields) {
406
- if (!isVirtualRelation(field) && !existingColumns.has(field.name)) {
407
- assertSafeIdentifier(field.name);
408
- const colDef = buildColumnDef(field);
409
- if (colDef) {
410
- await pool_default.query(`ALTER TABLE ${quotedTableName} ADD COLUMN IF NOT EXISTS ${colDef}`);
411
- console.log(`[plank] Added missing column "${field.name}" to table "${ct.tableName}"`);
412
- }
413
- }
414
- if (field.type === "relation" && (field.relationType ?? "many-to-one") === "many-to-many" && field.relatedTable) {
415
- await pool_default.query(buildJunctionTableSQL(ct.tableName, field.name, field.relatedTable));
416
- console.log(`[plank] Created missing junction table for "${ct.tableName}.${field.name}"`);
417
- }
418
- }
419
- }
420
- }
421
-
422
- // ../schema/dist/validator.js
423
- function validate(contentType, payload) {
424
- const errors = [];
425
- for (const field of contentType.fields) {
426
- const value = payload[field.name];
427
- const isEmpty = value === void 0 || value === null || value === "";
428
- if (field.required && isEmpty) {
429
- errors.push(`Field "${field.name}" is required`);
430
- continue;
431
- }
432
- if (isEmpty)
433
- continue;
434
- switch (field.type) {
435
- case "string":
436
- case "text":
437
- case "richtext":
438
- if (typeof value !== "string") {
439
- errors.push(`Field "${field.name}" must be a string`);
440
- }
441
- break;
442
- case "number":
443
- if (typeof value !== "number" || isNaN(value)) {
444
- errors.push(`Field "${field.name}" must be a number`);
445
- } else if (field.subtype !== "float" && !Number.isInteger(value)) {
446
- errors.push(`Field "${field.name}" must be an integer`);
447
- }
448
- break;
449
- case "boolean":
450
- if (typeof value !== "boolean") {
451
- errors.push(`Field "${field.name}" must be a boolean`);
452
- }
453
- break;
454
- case "datetime":
455
- if (!(value instanceof Date) && isNaN(Date.parse(String(value)))) {
456
- errors.push(`Field "${field.name}" must be a valid date`);
457
- }
458
- break;
459
- case "media":
460
- if (typeof value !== "string" || !value.trim()) {
461
- errors.push(`Field "${field.name}" must be a non-empty string URL`);
462
- }
463
- break;
464
- case "media-gallery":
465
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
466
- errors.push(`Field "${field.name}" must be an array of media IDs`);
467
- } else if (field.required && value.length === 0) {
468
- errors.push(`Field "${field.name}" is required`);
469
- }
470
- break;
471
- case "relation": {
472
- const rt = field.relationType ?? "many-to-one";
473
- if (rt === "one-to-many")
474
- break;
475
- if (rt === "many-to-many") {
476
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
477
- errors.push(`Field "${field.name}" must be an array of IDs`);
478
- } else if (field.required && value.length === 0) {
479
- errors.push(`Field "${field.name}" is required`);
480
- }
481
- } else {
482
- if (typeof value !== "string" || !value.trim()) {
483
- errors.push(`Field "${field.name}" must be a non-empty string ID`);
484
- }
485
- }
486
- break;
487
- }
488
- case "array":
489
- if (!Array.isArray(value)) {
490
- errors.push(`Field "${field.name}" must be an array`);
491
- } else if (field.required && value.length === 0) {
492
- errors.push(`Field "${field.name}" is required`);
493
- } else if (field.arrayFields && field.arrayFields.length > 0) {
494
- for (let i = 0; i < value.length; i++) {
495
- const item = value[i];
496
- if (typeof item !== "object" || item === null) {
497
- errors.push(`Field "${field.name}[${i}]" must be an object`);
498
- continue;
499
- }
500
- for (const subField of field.arrayFields) {
501
- const subValue = item[subField.name];
502
- const subEmpty = subValue === void 0 || subValue === null || subValue === "";
503
- if (subField.required && subEmpty) {
504
- errors.push(`Field "${field.name}[${i}].${subField.name}" is required`);
505
- }
506
- }
507
- }
508
- }
509
- break;
510
- }
511
- }
512
- if (errors.length > 0)
513
- throw new ValidationError(errors);
514
- }
515
-
516
- // ../core/dist/app.js
517
- import express from "express";
518
- import cors from "cors";
519
- import helmet from "helmet";
520
- import { join as join3, dirname as dirname2 } from "path";
521
- import { fileURLToPath as fileURLToPath2 } from "url";
522
-
523
- // ../core/dist/routes/auth.js
524
- import { Router } from "express";
525
-
526
- // ../core/dist/controllers/auth.js
527
- import bcrypt from "bcryptjs";
528
- import jwt from "jsonwebtoken";
529
- import { z, flattenError } from "zod";
530
-
531
- // ../core/dist/media/index.js
532
- import multer from "multer";
533
-
534
- // ../core/dist/lib/encrypt.js
535
- import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
536
- var ALGORITHM = "aes-256-gcm";
537
- var KEY_LENGTH = 32;
538
- var IV_LENGTH = 12;
539
- var TAG_LENGTH = 16;
540
- function getKey() {
541
- const raw = process.env.PLANK_ENCRYPTION_KEY;
542
- if (!raw)
543
- return null;
544
- const buf = Buffer.from(raw, "hex");
545
- if (buf.length !== KEY_LENGTH) {
546
- console.warn("[plank] PLANK_ENCRYPTION_KEY must be 64 hex chars (32 bytes). Falling back to plaintext storage.");
547
- return null;
548
- }
549
- return buf;
550
- }
551
- function encrypt(plaintext) {
552
- const key = getKey();
553
- if (!key)
554
- return plaintext;
555
- const iv = randomBytes(IV_LENGTH);
556
- const cipher = createCipheriv(ALGORITHM, key, iv);
557
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
558
- const tag = cipher.getAuthTag();
559
- return Buffer.concat([iv, tag, encrypted]).toString("hex");
560
- }
561
- function decrypt(stored) {
562
- const key = getKey();
563
- if (!key)
564
- return stored;
565
- try {
566
- const buf = Buffer.from(stored, "hex");
567
- const iv = buf.subarray(0, IV_LENGTH);
568
- const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
569
- const ciphertext = buf.subarray(IV_LENGTH + TAG_LENGTH);
570
- const decipher = createDecipheriv(ALGORITHM, key, iv);
571
- decipher.setAuthTag(tag);
572
- return decipher.update(ciphertext) + decipher.final("utf8");
573
- } catch {
574
- return stored;
575
- }
576
- }
577
-
578
- // ../core/dist/lib/settings.js
579
- var SENSITIVE_FIELDS = {
580
- media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"])
581
- };
582
- function isSensitive(namespace, key) {
583
- return SENSITIVE_FIELDS[namespace]?.has(key) ?? false;
584
- }
585
- async function getSettings(namespace) {
586
- const { rows } = await pool_default.query("SELECT key, value FROM plank_settings WHERE namespace = $1", [namespace]);
587
- return Object.fromEntries(rows.map((r) => [r.key, isSensitive(namespace, r.key) ? decrypt(r.value) : r.value]));
588
- }
589
- async function getSetting(namespace, key) {
590
- const { rows } = await pool_default.query("SELECT value FROM plank_settings WHERE namespace = $1 AND key = $2", [namespace, key]);
591
- if (!rows[0])
592
- return null;
593
- return isSensitive(namespace, key) ? decrypt(rows[0].value) : rows[0].value;
594
- }
595
- async function setSettings(namespace, values) {
596
- if (Object.keys(values).length === 0)
597
- return;
598
- const entries = Object.entries(values).map(([key, value]) => ({
599
- key,
600
- value: isSensitive(namespace, key) ? encrypt(value) : value
601
- }));
602
- const placeholders = entries.map((_, i) => `($1, $${i * 2 + 2}, $${i * 2 + 3})`).join(", ");
603
- const params = [namespace];
604
- for (const { key, value } of entries) {
605
- params.push(key, value);
606
- }
607
- await pool_default.query(`INSERT INTO plank_settings (namespace, key, value)
608
- VALUES ${placeholders}
609
- ON CONFLICT (namespace, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, params);
610
- }
611
-
612
- // ../core/dist/media/providers/local.js
613
- import { writeFile, mkdir } from "fs/promises";
614
- import { join as join2, extname } from "path";
615
- import { randomBytes as randomBytes2 } from "crypto";
616
- async function uploadsDir() {
617
- const fromSettings = await getSetting("media", "local.uploads_dir");
618
- return fromSettings ?? process.env.PLANK_UPLOADS_DIR ?? "public/uploads";
619
- }
620
- async function publicUrl() {
621
- const fromSettings = await getSetting("media", "local.public_url");
622
- return fromSettings ?? process.env.PLANK_PUBLIC_URL ?? "http://localhost:1337";
623
- }
624
- var localProvider = {
625
- async upload(file, options) {
626
- const base_dir = await uploadsDir();
627
- const subdir = options?.prefix ? join2(base_dir, options.prefix) : base_dir;
628
- await mkdir(subdir, { recursive: true });
629
- const ext = extname(file.originalname);
630
- const filename = `${randomBytes2(16).toString("hex")}${ext}`;
631
- const key = options?.prefix ? `${options.prefix}/${filename}` : filename;
632
- await writeFile(join2(base_dir, key), file.buffer);
633
- const base = await publicUrl();
634
- return { url: `${base}/uploads/${key}`, key };
635
- },
636
- async uploadRaw(buffer, exactKey, mimeType) {
637
- const base_dir = await uploadsDir();
638
- const dir = join2(base_dir, exactKey.split("/").slice(0, -1).join("/"));
639
- await mkdir(dir, { recursive: true });
640
- await writeFile(join2(base_dir, exactKey), buffer);
641
- const base = await publicUrl();
642
- return { url: `${base}/uploads/${exactKey}`, key: exactKey };
643
- },
644
- async delete(key) {
645
- const { unlink } = await import("fs/promises");
646
- const dir = await uploadsDir();
647
- await unlink(join2(dir, key));
648
- },
649
- async getUrl(key) {
650
- const base = await publicUrl();
651
- return `${base}/uploads/${key}`;
652
- }
653
- };
654
-
655
- // ../core/dist/media/providers/s3.js
656
- import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
657
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
658
- import { extname as extname2 } from "path";
659
- import { randomBytes as randomBytes3 } from "crypto";
660
- async function getConfig() {
661
- const [accessKeyId, secretAccessKey, region, bucket, pathPrefix, publicUrl2] = await Promise.all([
662
- getSetting("media", "s3.access_key_id"),
663
- getSetting("media", "s3.secret_access_key"),
664
- getSetting("media", "s3.region"),
665
- getSetting("media", "s3.bucket"),
666
- getSetting("media", "s3.path_prefix"),
667
- getSetting("media", "s3.public_url")
668
- ]);
669
- return { accessKeyId, secretAccessKey, region, bucket, pathPrefix, publicUrl: publicUrl2 };
670
- }
671
- function buildClient(cfg) {
672
- if (!cfg.accessKeyId || !cfg.secretAccessKey || !cfg.region) {
673
- throw new Error("S3 provider is not configured. Set access_key_id, secret_access_key, and region in Settings > Media.");
674
- }
675
- return new S3Client({
676
- region: cfg.region,
677
- credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey }
678
- });
679
- }
680
- function buildKey(cfg, filename, prefix) {
681
- const ext = extname2(filename);
682
- const name = `${randomBytes3(16).toString("hex")}${ext}`;
683
- const parts = [cfg.pathPrefix?.replace(/\/$/, ""), prefix, name].filter(Boolean);
684
- return parts.join("/");
685
- }
686
- function buildStoredUrl(cfg, key) {
687
- return cfg.publicUrl ? `${cfg.publicUrl.replace(/\/$/, "")}/${key}` : `https://${cfg.bucket}.s3.${cfg.region}.amazonaws.com/${key}`;
688
- }
689
- var s3Provider = {
690
- async upload(file, options) {
691
- const cfg = await getConfig();
692
- const client = buildClient(cfg);
693
- const key = buildKey(cfg, file.originalname, options?.prefix);
694
- await client.send(new PutObjectCommand({
695
- Bucket: cfg.bucket,
696
- Key: key,
697
- Body: file.buffer,
698
- ContentType: file.mimetype
699
- }));
700
- return { url: buildStoredUrl(cfg, key), key };
701
- },
702
- async uploadRaw(buffer, exactKey, mimeType) {
703
- const cfg = await getConfig();
704
- const client = buildClient(cfg);
705
- await client.send(new PutObjectCommand({
706
- Bucket: cfg.bucket,
707
- Key: exactKey,
708
- Body: buffer,
709
- ContentType: mimeType
710
- }));
711
- return { url: buildStoredUrl(cfg, exactKey), key: exactKey };
712
- },
713
- async delete(key) {
714
- const cfg = await getConfig();
715
- const client = buildClient(cfg);
716
- await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key }));
717
- },
718
- async getUrl(key) {
719
- const cfg = await getConfig();
720
- return buildStoredUrl(cfg, key);
721
- },
722
- async presign(filename, mimeType, options) {
723
- const cfg = await getConfig();
724
- const client = buildClient(cfg);
725
- const key = buildKey(cfg, filename, options?.prefix);
726
- const command = new PutObjectCommand({ Bucket: cfg.bucket, Key: key, ContentType: mimeType });
727
- const uploadUrl = await getSignedUrl(client, command, { expiresIn: 300 });
728
- return { key, uploadUrl, publicUrl: buildStoredUrl(cfg, key) };
729
- }
730
- };
731
-
732
- // ../core/dist/media/providers/r2.js
733
- import { S3Client as S3Client2, PutObjectCommand as PutObjectCommand2, DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
734
- import { getSignedUrl as getSignedUrl2 } from "@aws-sdk/s3-request-presigner";
735
- import { extname as extname3 } from "path";
736
- import { randomBytes as randomBytes4 } from "crypto";
737
- async function getConfig2() {
738
- const [accessKeyId, secretAccessKey, accountId, bucket, pathPrefix, publicUrl2] = await Promise.all([
739
- getSetting("media", "r2.access_key_id"),
740
- getSetting("media", "r2.secret_access_key"),
741
- getSetting("media", "r2.account_id"),
742
- getSetting("media", "r2.bucket"),
743
- getSetting("media", "r2.path_prefix"),
744
- getSetting("media", "r2.public_url")
745
- ]);
746
- return { accessKeyId, secretAccessKey, accountId, bucket, pathPrefix, publicUrl: publicUrl2 };
747
- }
748
- function buildClient2(cfg) {
749
- if (!cfg.accessKeyId || !cfg.secretAccessKey || !cfg.accountId) {
750
- throw new Error("R2 provider is not configured. Set access_key_id, secret_access_key, and account_id in Settings > Media.");
751
- }
752
- return new S3Client2({
753
- region: "auto",
754
- endpoint: `https://${cfg.accountId}.r2.cloudflarestorage.com`,
755
- credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
756
- requestChecksumCalculation: "WHEN_REQUIRED",
757
- responseChecksumValidation: "WHEN_REQUIRED"
758
- });
759
- }
760
- function buildKey2(cfg, filename, prefix) {
761
- const ext = extname3(filename);
762
- const name = `${randomBytes4(16).toString("hex")}${ext}`;
763
- const parts = [cfg.pathPrefix?.replace(/\/$/, ""), prefix, name].filter(Boolean);
764
- return parts.join("/");
765
- }
766
- function buildStoredUrl2(cfg, key) {
767
- if (!cfg.publicUrl) {
768
- throw new Error("R2 provider requires a public_url configured in Settings > Media.");
769
- }
770
- return `${cfg.publicUrl.replace(/\/$/, "")}/${key}`;
771
- }
772
- var r2Provider = {
773
- async upload(file, options) {
774
- const cfg = await getConfig2();
775
- const client = buildClient2(cfg);
776
- const key = buildKey2(cfg, file.originalname, options?.prefix);
777
- await client.send(new PutObjectCommand2({
778
- Bucket: cfg.bucket,
779
- Key: key,
780
- Body: file.buffer,
781
- ContentType: file.mimetype
782
- }));
783
- return { url: buildStoredUrl2(cfg, key), key };
784
- },
785
- async uploadRaw(buffer, exactKey, mimeType) {
786
- const cfg = await getConfig2();
787
- const client = buildClient2(cfg);
788
- await client.send(new PutObjectCommand2({
789
- Bucket: cfg.bucket,
790
- Key: exactKey,
791
- Body: buffer,
792
- ContentType: mimeType
793
- }));
794
- return { url: buildStoredUrl2(cfg, exactKey), key: exactKey };
795
- },
796
- async delete(key) {
797
- const cfg = await getConfig2();
798
- const client = buildClient2(cfg);
799
- await client.send(new DeleteObjectCommand2({ Bucket: cfg.bucket, Key: key }));
800
- },
801
- async getUrl(key) {
802
- const cfg = await getConfig2();
803
- return buildStoredUrl2(cfg, key);
804
- },
805
- async presign(filename, mimeType, options) {
806
- const cfg = await getConfig2();
807
- const client = buildClient2(cfg);
808
- const key = buildKey2(cfg, filename, options?.prefix);
809
- const command = new PutObjectCommand2({ Bucket: cfg.bucket, Key: key, ContentType: mimeType });
810
- const uploadUrl = await getSignedUrl2(client, command, { expiresIn: 300 });
811
- return { key, uploadUrl, publicUrl: buildStoredUrl2(cfg, key) };
812
- }
813
- };
814
-
815
- // ../core/dist/media/index.js
816
- var providers = {
817
- local: localProvider,
818
- s3: s3Provider,
819
- r2: r2Provider
820
- };
821
- async function getProvider() {
822
- const fromSettings = await getSetting("media", "provider");
823
- const name = fromSettings ?? process.env.PLANK_MEDIA_PROVIDER ?? "local";
824
- const provider = providers[name];
825
- if (!provider)
826
- throw new Error(`Unknown media provider: "${name}". Use local, s3, or r2.`);
827
- return provider;
828
- }
829
- var upload = multer({ storage: multer.memoryStorage() });
830
-
831
- // ../core/dist/controllers/auth.js
832
- var LoginSchema = z.object({
833
- email: z.email(),
834
- password: z.string().min(1)
835
- });
836
- var RegisterSchema = z.object({
837
- email: z.email(),
838
- password: z.string().min(8)
839
- });
840
- var loginAttempts = /* @__PURE__ */ new Map();
841
- var RATE_LIMIT_MAX = 10;
842
- var RATE_LIMIT_WINDOW_MS = 15 * 60 * 1e3;
843
- function checkRateLimit(ip) {
844
- const now = Date.now();
845
- const entry = loginAttempts.get(ip);
846
- if (!entry || now > entry.resetAt) {
847
- loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
848
- return true;
849
- }
850
- if (entry.count >= RATE_LIMIT_MAX)
851
- return false;
852
- entry.count++;
853
- return true;
854
- }
855
- function clearRateLimit(ip) {
856
- loginAttempts.delete(ip);
857
- }
858
- async function login(req, res) {
859
- const ip = req.ip ?? "unknown";
860
- if (!checkRateLimit(ip)) {
861
- res.status(429).json({ error: "Too many login attempts. Try again in 15 minutes." });
862
- return;
863
- }
864
- const parsed = LoginSchema.safeParse(req.body);
865
- if (!parsed.success) {
866
- res.status(400).json({ errors: flattenError(parsed.error, (i) => i.message) });
867
- return;
868
- }
869
- const { email, password } = parsed.data;
870
- const { rows } = await pool_default.query(`SELECT id, email, password, role_id, first_name, last_name, avatar_url, job_title, organization, country
871
- FROM plank_users
872
- WHERE email = $1`, [email]);
873
- const user = rows[0];
874
- if (!user || !await bcrypt.compare(password, user.password)) {
875
- res.status(401).json({ error: "Invalid credentials" });
876
- return;
877
- }
878
- const { rows: roleRows } = await pool_default.query("SELECT id, name, permissions FROM plank_roles WHERE id = $1", [user.role_id]);
879
- clearRateLimit(ip);
880
- let avatarUrl = user.avatar_url;
881
- if (avatarUrl && !avatarUrl.startsWith("http")) {
882
- const provider = await getProvider();
883
- avatarUrl = await provider.getUrl(avatarUrl);
884
- }
885
- const token = jwt.sign({ sub: user.id, roleId: user.role_id }, process.env.PLANK_JWT_SECRET, { expiresIn: "7d" });
886
- res.json({
887
- token,
888
- user: {
889
- id: user.id,
890
- email: user.email,
891
- role: roleRows[0]?.name ?? "unknown",
892
- permissions: roleRows[0]?.permissions ?? [],
893
- firstName: user.first_name,
894
- lastName: user.last_name,
895
- avatarUrl,
896
- jobTitle: user.job_title,
897
- organization: user.organization,
898
- country: user.country
899
- }
900
- });
901
- }
902
- async function setup(_req, res) {
903
- const { rows } = await pool_default.query("SELECT COUNT(*) as count FROM plank_users");
904
- res.json({ needsSetup: parseInt(rows[0].count) === 0 });
905
- }
906
- async function register(req, res) {
907
- const { rows: countRows } = await pool_default.query("SELECT COUNT(*) as count FROM plank_users");
908
- if (parseInt(countRows[0].count) > 0) {
909
- res.status(403).json({ error: "Registration is closed. Use the admin panel to manage users." });
910
- return;
911
- }
912
- const parsed = RegisterSchema.safeParse(req.body);
913
- if (!parsed.success) {
914
- res.status(400).json({ errors: flattenError(parsed.error, (i) => i.message) });
915
- return;
916
- }
917
- const { email, password } = parsed.data;
918
- const hashed = await bcrypt.hash(password, 12);
919
- const { rows: roleRows } = await pool_default.query("SELECT id, name FROM plank_roles WHERE name = $1", ["Super Admin"]);
920
- const id = createId();
921
- await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, roleRows[0].id]);
922
- res.status(201).json({ id, email });
923
- }
924
-
925
- // ../core/dist/routes/auth.js
926
- var router = Router();
927
- router.get("/setup", setup);
928
- router.post("/login", login);
929
- router.post("/register", register);
930
- var auth_default = router;
931
-
932
- // ../core/dist/routes/admin.js
933
- import { Router as Router2 } from "express";
934
-
935
- // ../core/dist/middlewares/authenticate.js
936
- import jwt2 from "jsonwebtoken";
937
- function authenticate(req, res, next) {
938
- const header = req.headers.authorization;
939
- if (!header?.startsWith("Bearer ")) {
940
- res.status(401).json({ error: "Unauthorized" });
941
- return;
942
- }
943
- const token = header.slice(7);
944
- try {
945
- const payload = jwt2.verify(token, process.env.PLANK_JWT_SECRET);
946
- req.user = { id: payload.sub, roleId: payload.roleId };
947
- next();
948
- } catch {
949
- res.status(401).json({ error: "Invalid or expired token" });
950
- }
951
- }
952
-
953
- // ../core/dist/middlewares/authorize.js
954
- function authorize(permission) {
955
- return async (req, res, next) => {
956
- const { rows } = await pool_default.query("SELECT permissions FROM plank_roles WHERE id = $1", [req.user.roleId]);
957
- const permissions = rows[0]?.permissions ?? [];
958
- if (permissions.includes("*") || permissions.includes(permission)) {
959
- next();
960
- } else {
961
- res.status(403).json({ error: "Forbidden" });
962
- }
963
- };
964
- }
965
-
966
- // ../core/dist/controllers/contentTypes.js
967
- import { z as z2, flattenError as flattenError2 } from "zod";
968
- var RESERVED_SQL_IDENTIFIERS = /* @__PURE__ */ new Set([
969
- "all",
970
- "analyse",
971
- "analyze",
972
- "and",
973
- "any",
974
- "array",
975
- "as",
976
- "asc",
977
- "asymmetric",
978
- "authorization",
979
- "between",
980
- "binary",
981
- "both",
982
- "case",
983
- "cast",
984
- "check",
985
- "collate",
986
- "column",
987
- "constraint",
988
- "create",
989
- "cross",
990
- "current_catalog",
991
- "current_date",
992
- "current_role",
993
- "current_schema",
994
- "current_time",
995
- "current_timestamp",
996
- "current_user",
997
- "default",
998
- "deferrable",
999
- "desc",
1000
- "distinct",
1001
- "do",
1002
- "else",
1003
- "end",
1004
- "except",
1005
- "false",
1006
- "fetch",
1007
- "for",
1008
- "foreign",
1009
- "from",
1010
- "grant",
1011
- "group",
1012
- "having",
1013
- "in",
1014
- "initially",
1015
- "intersect",
1016
- "into",
1017
- "lateral",
1018
- "leading",
1019
- "limit",
1020
- "localtime",
1021
- "localtimestamp",
1022
- "not",
1023
- "null",
1024
- "offset",
1025
- "on",
1026
- "only",
1027
- "or",
1028
- "order",
1029
- "placing",
1030
- "primary",
1031
- "references",
1032
- "returning",
1033
- "select",
1034
- "session_user",
1035
- "some",
1036
- "symmetric",
1037
- "table",
1038
- "then",
1039
- "to",
1040
- "trailing",
1041
- "true",
1042
- "union",
1043
- "unique",
1044
- "user",
1045
- "using",
1046
- "variadic",
1047
- "when",
1048
- "where",
1049
- "window",
1050
- "with"
1051
- ]);
1052
- var ArraySubFieldSchema = z2.object({
1053
- name: z2.string().regex(/^[a-z][a-z0-9_]*$/, "Sub-field name must be lowercase with underscores"),
1054
- type: z2.enum(["string", "text", "richtext", "number", "boolean", "datetime", "media"]),
1055
- required: z2.boolean().optional(),
1056
- subtype: z2.enum(["integer", "float"]).optional(),
1057
- allowedTypes: z2.array(z2.enum(["image", "video", "audio", "document"])).optional(),
1058
- width: z2.enum(["full", "two-thirds", "half", "third"]).optional()
1059
- });
1060
- var FieldSchema = z2.object({
1061
- name: z2.string().regex(/^[a-z][a-z0-9_]*$/, "Field name must be lowercase with underscores"),
1062
- type: z2.enum(["string", "text", "richtext", "number", "boolean", "datetime", "media", "media-gallery", "relation", "uid", "array"]),
1063
- required: z2.boolean().optional(),
1064
- subtype: z2.enum(["integer", "float"]).optional(),
1065
- relationType: z2.enum(["many-to-one", "one-to-one", "one-to-many", "many-to-many"]).optional(),
1066
- relatedTable: z2.string().optional(),
1067
- relatedSlug: z2.string().optional(),
1068
- relatedField: z2.string().optional(),
1069
- targetField: z2.string().optional(),
1070
- allowedTypes: z2.array(z2.enum(["image", "video", "audio", "document"])).optional(),
1071
- width: z2.enum(["full", "two-thirds", "half", "third"]).optional(),
1072
- arrayFields: z2.array(ArraySubFieldSchema).optional()
1073
- });
1074
- var ContentTypeSchema = z2.object({
1075
- name: z2.string().min(1),
1076
- slug: z2.string().regex(/^[a-z][a-z0-9-]*$/, "Slug must be lowercase with hyphens"),
1077
- tableName: z2.string().regex(/^[a-z][a-z0-9_]*$/, "Table name must be lowercase with underscores"),
1078
- fields: z2.array(FieldSchema)
1079
- });
1080
- var CreateContentTypeSchema = ContentTypeSchema.extend({
1081
- kind: z2.enum(["collection", "single"]).default("collection")
1082
- });
1083
- function isReservedSqlIdentifier(name) {
1084
- return RESERVED_SQL_IDENTIFIERS.has(name.toLowerCase());
1085
- }
1086
- function findReservedIdentifierErrors(ct) {
1087
- const errors = [];
1088
- if (isReservedSqlIdentifier(ct.tableName)) {
1089
- errors.push(`Table name "${ct.tableName}" is reserved by SQL. Choose a different content type slug.`);
1090
- }
1091
- for (const field of ct.fields) {
1092
- if (isReservedSqlIdentifier(field.name)) {
1093
- errors.push(`Field name "${field.name}" is reserved by SQL.`);
1094
- }
1095
- if (field.type === "array") {
1096
- for (const subField of field.arrayFields ?? []) {
1097
- if (isReservedSqlIdentifier(subField.name)) {
1098
- errors.push(`Sub-field name "${subField.name}" in array field "${field.name}" is reserved by SQL.`);
1099
- }
1100
- }
1101
- }
1102
- }
1103
- return errors;
1104
- }
1105
- function inverseRelationType(rt) {
1106
- if (rt === "many-to-one")
1107
- return "one-to-many";
1108
- if (rt === "one-to-many")
1109
- return "many-to-one";
1110
- return rt;
1111
- }
1112
- function inverseFieldName(sourceTable, sourceFieldName, existingNames) {
1113
- const simple = sourceTable;
1114
- if (!existingNames.includes(simple))
1115
- return simple;
1116
- return `${sourceTable}_${sourceFieldName}`;
1117
- }
1118
- async function syncInverseFields(savedCT, prevCT) {
1119
- const relatedCTCache = /* @__PURE__ */ new Map();
1120
- async function getRelatedCT(tableName) {
1121
- if (relatedCTCache.has(tableName))
1122
- return relatedCTCache.get(tableName) ?? null;
1123
- const all = await findAllContentTypes();
1124
- const ct = all.find((c) => c.tableName === tableName) ?? null;
1125
- relatedCTCache.set(tableName, ct);
1126
- return ct;
1127
- }
1128
- const prevRelFields = new Map((prevCT?.fields ?? []).filter((f) => f.type === "relation" && f.relationType !== "one-to-many").map((f) => [f.name, f]));
1129
- const nextRelFields = savedCT.fields.filter((f) => f.type === "relation" && f.relationType !== "one-to-many");
1130
- for (const [fieldName, prevField] of prevRelFields) {
1131
- if (!prevField.relatedTable)
1132
- continue;
1133
- const nextField = savedCT.fields.find((f) => f.name === fieldName);
1134
- const targetChanged = nextField?.relatedTable !== prevField.relatedTable;
1135
- if (!nextField || targetChanged) {
1136
- const relatedCT = await getRelatedCT(prevField.relatedTable);
1137
- if (!relatedCT)
1138
- continue;
1139
- const updated = relatedCT.fields.filter((f) => !(f.type === "relation" && f.relationType === inverseRelationType(prevField.relationType ?? "many-to-one") && f.relatedTable === savedCT.tableName && f.relatedField === fieldName));
1140
- if (updated.length !== relatedCT.fields.length) {
1141
- await updateContentType(relatedCT.slug, { ...relatedCT, fields: updated });
1142
- }
1143
- }
1144
- }
1145
- for (const field of nextRelFields) {
1146
- if (!field.relatedTable)
1147
- continue;
1148
- const relatedCT = await getRelatedCT(field.relatedTable);
1149
- if (!relatedCT)
1150
- continue;
1151
- const invType = inverseRelationType(field.relationType ?? "many-to-one");
1152
- const existingInvIdx = relatedCT.fields.findIndex((f) => f.type === "relation" && f.relationType === invType && f.relatedTable === savedCT.tableName && f.relatedField === field.name);
1153
- const invField = {
1154
- name: existingInvIdx >= 0 ? relatedCT.fields[existingInvIdx].name : inverseFieldName(savedCT.tableName, field.name, relatedCT.fields.map((f) => f.name)),
1155
- type: "relation",
1156
- relationType: invType,
1157
- relatedTable: savedCT.tableName,
1158
- relatedSlug: savedCT.slug,
1159
- relatedField: field.name
1160
- };
1161
- let updatedFields;
1162
- if (existingInvIdx >= 0) {
1163
- updatedFields = relatedCT.fields.map((f, i) => i === existingInvIdx ? invField : f);
1164
- } else {
1165
- updatedFields = [...relatedCT.fields, invField];
1166
- }
1167
- await updateContentType(relatedCT.slug, { ...relatedCT, fields: updatedFields });
1168
- }
1169
- }
1170
- async function removeRelationDependencies(deletedTable) {
1171
- const all = await findAllContentTypes();
1172
- for (const ct of all) {
1173
- const toRemove = ct.fields.filter((f) => f.type === "relation" && f.relatedTable === deletedTable.tableName);
1174
- if (toRemove.length === 0)
1175
- continue;
1176
- for (const field of toRemove) {
1177
- const relType = field.relationType ?? "many-to-one";
1178
- if (relType === "many-to-one" || relType === "one-to-one") {
1179
- try {
1180
- await pool_default.query(`ALTER TABLE ${quoteIdentifier(ct.tableName)} DROP COLUMN IF EXISTS ${quoteIdentifier(field.name)}`);
1181
- } catch {
1182
- }
1183
- }
1184
- if (relType === "many-to-many") {
1185
- const jt = `_rel_${ct.tableName}_${field.name}`;
1186
- await pool_default.query(`DROP TABLE IF EXISTS ${quoteIdentifier(jt)}`);
1187
- }
1188
- }
1189
- const filtered = ct.fields.filter((f) => !(f.type === "relation" && f.relatedTable === deletedTable.tableName));
1190
- await updateContentType(ct.slug, { ...ct, fields: filtered });
1191
- }
1192
- }
1193
- var listContentTypes = async (_req, res) => {
1194
- const contentTypes = await findAllContentTypes();
1195
- res.json(contentTypes);
1196
- };
1197
- var getContentType = async (req, res) => {
1198
- const ct = await findContentTypeBySlug(req.params.slug);
1199
- if (!ct) {
1200
- res.status(404).json({ error: "Content type not found" });
1201
- return;
1202
- }
1203
- res.json(ct);
1204
- };
1205
- var createContentType = async (req, res) => {
1206
- const parsed = CreateContentTypeSchema.safeParse(req.body);
1207
- if (!parsed.success) {
1208
- res.status(400).json({ errors: flattenError2(parsed.error, (i) => i.message) });
1209
- return;
1210
- }
1211
- const reservedErrors = findReservedIdentifierErrors(parsed.data);
1212
- if (reservedErrors.length > 0) {
1213
- res.status(400).json({ errors: { formErrors: reservedErrors, fieldErrors: {} } });
1214
- return;
1215
- }
1216
- const ct = await saveContentType(parsed.data);
1217
- await createTable(ct);
1218
- try {
1219
- await syncInverseFields(ct, null);
1220
- } catch (err) {
1221
- console.error("[plank] syncInverseFields failed:", err);
1222
- }
1223
- res.status(201).json(ct);
1224
- };
1225
- var updateContentType2 = async (req, res) => {
1226
- const prev = await findContentTypeBySlug(req.params.slug);
1227
- if (!prev) {
1228
- res.status(404).json({ error: "Content type not found" });
1229
- return;
1230
- }
1231
- const parsed = ContentTypeSchema.safeParse(req.body);
1232
- if (!parsed.success) {
1233
- res.status(400).json({ errors: flattenError2(parsed.error, (i) => i.message) });
1234
- return;
1235
- }
1236
- const reservedErrors = findReservedIdentifierErrors(parsed.data);
1237
- if (reservedErrors.length > 0) {
1238
- res.status(400).json({ errors: { formErrors: reservedErrors, fieldErrors: {} } });
1239
- return;
1240
- }
1241
- const next = await updateContentType(req.params.slug, { ...parsed.data, kind: prev.kind });
1242
- await syncTable(next, prev);
1243
- try {
1244
- await syncInverseFields(next, prev);
1245
- } catch (err) {
1246
- console.error("[plank] syncInverseFields failed:", err);
1247
- }
1248
- res.json(next);
1249
- };
1250
- var setDefaultContentType2 = async (req, res) => {
1251
- const ct = await setDefaultContentType(req.params.slug);
1252
- res.json(ct);
1253
- };
1254
- var deleteContentType2 = async (req, res) => {
1255
- const ct = await findContentTypeBySlug(req.params.slug);
1256
- if (!ct) {
1257
- res.status(404).json({ error: "Content type not found" });
1258
- return;
1259
- }
1260
- assertSafeIdentifier(ct.tableName);
1261
- await removeRelationDependencies(ct);
1262
- await pool_default.query(`DROP TABLE IF EXISTS ${ct.tableName} CASCADE`);
1263
- await deleteContentType(req.params.slug);
1264
- res.status(204).end();
1265
- };
1266
-
1267
- // ../core/dist/controllers/webhooks.js
1268
- import { z as z3, flattenError as flattenError3 } from "zod";
1269
- var CreateWebhookSchema = z3.object({
1270
- name: z3.string().min(1),
1271
- url: z3.string().url(),
1272
- events: z3.array(z3.enum(["entry.created", "entry.updated", "entry.deleted", "entry.published", "entry.unpublished"])).min(1)
1273
- });
1274
- async function listWebhooks(_req, res) {
1275
- try {
1276
- const { rows } = await pool_default.query("SELECT id, name, url, events, enabled, created_at FROM plank_webhooks ORDER BY created_at DESC");
1277
- res.json(rows);
1278
- } catch {
1279
- res.status(500).json({ error: "Failed to list webhooks" });
1280
- }
1281
- }
1282
- async function createWebhook(req, res) {
1283
- const parsed = CreateWebhookSchema.safeParse(req.body);
1284
- if (!parsed.success) {
1285
- res.status(400).json({ errors: flattenError3(parsed.error, (i) => i.message) });
1286
- return;
1287
- }
1288
- const { name, url, events } = parsed.data;
1289
- const id = createId();
1290
- try {
1291
- const { rows } = await pool_default.query("INSERT INTO plank_webhooks (id, name, url, events) VALUES ($1, $2, $3, $4::text[]) RETURNING *", [id, name, url, events]);
1292
- res.status(201).json(rows[0]);
1293
- } catch (err) {
1294
- console.error("[webhooks] create error:", err);
1295
- res.status(500).json({ error: "Failed to create webhook" });
1296
- }
1297
- }
1298
- async function deleteWebhook(req, res) {
1299
- try {
1300
- const { rowCount } = await pool_default.query("DELETE FROM plank_webhooks WHERE id = $1", [req.params.id]);
1301
- if (!rowCount) {
1302
- res.status(404).json({ error: "Webhook not found" });
1303
- return;
1304
- }
1305
- res.status(204).end();
1306
- } catch {
1307
- res.status(500).json({ error: "Failed to delete webhook" });
1308
- }
1309
- }
1310
- async function triggerWebhooks(event, payload) {
1311
- const { rows } = await pool_default.query("SELECT * FROM plank_webhooks WHERE enabled = TRUE AND $1 = ANY(events)", [event]);
1312
- if (rows.length === 0)
1313
- return;
1314
- const body = JSON.stringify({ event, ...payload, triggered_at: (/* @__PURE__ */ new Date()).toISOString() });
1315
- await Promise.allSettled(rows.map((webhook) => fetch(webhook.url, {
1316
- method: "POST",
1317
- headers: { "Content-Type": "application/json" },
1318
- body,
1319
- signal: AbortSignal.timeout(1e4)
1320
- })));
1321
- }
1322
-
1323
- // ../core/dist/controllers/entries.js
1324
- function resolveLocalizedRow(row, ct, locale, fallbacks = []) {
1325
- const localized = row.localized && typeof row.localized === "object" ? row.localized : {};
1326
- const resolved = { ...row };
1327
- const localizableTypes = /* @__PURE__ */ new Set(["string", "text", "richtext", "uid"]);
1328
- for (const f of ct.fields) {
1329
- if (!localizableTypes.has(f.type))
1330
- continue;
1331
- let val = void 0;
1332
- if (locale && localized[locale] && localized[locale][f.name] !== void 0) {
1333
- val = localized[locale][f.name];
1334
- } else {
1335
- for (const fb of fallbacks) {
1336
- if (localized[fb] && localized[fb][f.name] !== void 0) {
1337
- val = localized[fb][f.name];
1338
- break;
1339
- }
1340
- }
1341
- }
1342
- if (val !== void 0)
1343
- resolved[f.name] = val;
1344
- }
1345
- return resolved;
1346
- }
1347
- function junctionTableName2(sourceTable, fieldName) {
1348
- return `_rel_${sourceTable}_${fieldName}`;
1349
- }
1350
- async function syncManyToMany(entryId, tableName, field, targetIds) {
1351
- const jt = junctionTableName2(tableName, field.name);
1352
- await pool_default.query(`DELETE FROM ${quoteIdentifier(jt)} WHERE source_id = $1`, [entryId]);
1353
- if (targetIds.length === 0)
1354
- return;
1355
- const placeholders = targetIds.map((_, i) => `($1, $${i + 2})`).join(", ");
1356
- await pool_default.query(`INSERT INTO ${quoteIdentifier(jt)} (source_id, target_id) VALUES ${placeholders} ON CONFLICT DO NOTHING`, [entryId, ...targetIds]);
1357
- }
1358
- async function isUserRole(roleId) {
1359
- if (!roleId)
1360
- return false;
1361
- const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
1362
- return rows[0]?.name?.toLowerCase() === "user";
1363
- }
1364
- var listEntries = async (req, res) => {
1365
- const ct = await findContentTypeBySlug(req.params.slug);
1366
- if (!ct) {
1367
- res.status(404).json({ error: "Content type not found" });
1368
- return;
1369
- }
1370
- assertSafeIdentifier(ct.tableName);
1371
- const quotedTableName = quoteIdentifier(ct.tableName);
1372
- const isUser = await isUserRole(req.user?.roleId);
1373
- const page = Math.max(1, parseInt(String(req.query.page ?? 1)));
1374
- const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? 20))));
1375
- const offset = (page - 1) * limit;
1376
- const allowedSort = ["created_at", "updated_at", "published_at", ...ct.fields.map((f) => f.name)];
1377
- const sortField = allowedSort.includes(String(req.query.sort ?? "")) ? String(req.query.sort) : "created_at";
1378
- const sortDir = req.query.order === "asc" ? "ASC" : "DESC";
1379
- assertSafeIdentifier(sortField);
1380
- const quotedSortField = quoteIdentifier(sortField);
1381
- const locale = req.query.locale ? String(req.query.locale) : void 0;
1382
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
1383
- const ownCollectionOnly = isUser && ct.kind === "collection";
1384
- const whereClause = ownCollectionOnly ? "WHERE e.created_by = $3" : "";
1385
- const countWhereClause = ownCollectionOnly ? "WHERE created_by = $1" : "";
1386
- const listValues = ownCollectionOnly ? [limit, offset, req.user?.id ?? null] : [limit, offset];
1387
- const countValues = ownCollectionOnly ? [req.user?.id ?? null] : [];
1388
- const [{ rows }, { rows: countRows }] = await Promise.all([
1389
- 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
1390
- FROM ${quotedTableName} e
1391
- LEFT JOIN plank_users u ON u.id = e.created_by
1392
- ${whereClause}
1393
- ORDER BY e.${quotedSortField} ${sortDir}
1394
- LIMIT $1 OFFSET $2`, listValues),
1395
- pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} ${countWhereClause}`, countValues)
1396
- ]);
1397
- const provider = await getProvider();
1398
- function entryMatchesLocale(row, locale2) {
1399
- if (!locale2)
1400
- return true;
1401
- const localized = row.localized && typeof row.localized === "object" ? row.localized : {};
1402
- const locales = Object.keys(localized).filter((k) => !k.startsWith("_"));
1403
- const meta = localized._meta || {};
1404
- const enabled = meta.enabled ?? locales.length > 0;
1405
- const primary = meta.primary;
1406
- if (enabled) {
1407
- return Boolean(localized[locale2]) || primary === locale2;
1408
- }
1409
- return primary === locale2;
1410
- }
1411
- const filtered = rows.filter((r) => entryMatchesLocale(r, locale));
1412
- const data = await Promise.all(filtered.map(async (row) => {
1413
- const mmIds = await loadManyToManyIds(row.id, ct.tableName, ct.fields);
1414
- const resolved = resolveLocalizedRow(row, ct, locale, fallbacks);
1415
- const key = resolved._author_avatar_url;
1416
- if (key && !key.startsWith("http")) {
1417
- resolved._author_avatar_url = await provider.getUrl(key);
1418
- }
1419
- return { ...resolved, ...mmIds };
1420
- }));
1421
- let total = parseInt(countRows[0].count);
1422
- if (locale) {
1423
- try {
1424
- const { rows: allRows } = await pool_default.query(`SELECT localized FROM ${quotedTableName}`);
1425
- const matching = allRows.filter((r) => entryMatchesLocale(r, locale));
1426
- total = matching.length;
1427
- } catch (err) {
1428
- }
1429
- }
1430
- res.json({ data, total, page, limit });
1431
- };
1432
- async function loadManyToManyIds(entryId, tableName, fields) {
1433
- const mmFields = fields.filter((f) => f.type === "relation" && (f.relationType ?? "many-to-one") === "many-to-many");
1434
- if (mmFields.length === 0)
1435
- return {};
1436
- const result = {};
1437
- await Promise.all(mmFields.map(async (f) => {
1438
- const jt = junctionTableName2(tableName, f.name);
1439
- const { rows } = await pool_default.query(`SELECT target_id FROM ${quoteIdentifier(jt)} WHERE source_id = $1`, [entryId]);
1440
- result[f.name] = rows.map((r) => r.target_id);
1441
- }));
1442
- return result;
1443
- }
1444
- var getEntry = async (req, res) => {
1445
- const ct = await findContentTypeBySlug(req.params.slug);
1446
- if (!ct) {
1447
- res.status(404).json({ error: "Content type not found" });
1448
- return;
1449
- }
1450
- assertSafeIdentifier(ct.tableName);
1451
- const quotedTableName = quoteIdentifier(ct.tableName);
1452
- const isUser = await isUserRole(req.user?.roleId);
1453
- const { rows } = await pool_default.query(`SELECT * FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
1454
- if (!rows[0]) {
1455
- res.status(404).json({ error: "Entry not found" });
1456
- return;
1457
- }
1458
- if (isUser && ct.kind === "collection" && rows[0].created_by !== req.user?.id) {
1459
- res.status(403).json({ error: "Forbidden" });
1460
- return;
1461
- }
1462
- const locale = req.query.locale ? String(req.query.locale) : void 0;
1463
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
1464
- const mmIds = await loadManyToManyIds(req.params.id, ct.tableName, ct.fields);
1465
- const provider = await getProvider();
1466
- const resolved = resolveLocalizedRow(rows[0], ct, locale, fallbacks);
1467
- const key = resolved._author_avatar_url;
1468
- if (key && !key.startsWith("http"))
1469
- resolved._author_avatar_url = await provider.getUrl(key);
1470
- res.json({ ...resolved, ...mmIds });
1471
- };
1472
- var createEntry = async (req, res) => {
1473
- const ct = await findContentTypeBySlug(req.params.slug);
1474
- if (!ct) {
1475
- res.status(404).json({ error: "Content type not found" });
1476
- return;
1477
- }
1478
- validate(ct, req.body);
1479
- assertSafeIdentifier(ct.tableName);
1480
- const quotedTableName = quoteIdentifier(ct.tableName);
1481
- const isUser = await isUserRole(req.user?.roleId);
1482
- if (isUser && ct.kind === "single") {
1483
- res.status(403).json({ error: "Single types are read-only for User role" });
1484
- return;
1485
- }
1486
- const mmFields = ct.fields.filter((f) => f.type === "relation" && (f.relationType ?? "many-to-one") === "many-to-many" && req.body[f.name] !== void 0);
1487
- const fields = ct.fields.filter((f) => req.body[f.name] !== void 0 && !isVirtualRelation(f));
1488
- fields.forEach((f) => assertSafeIdentifier(f.name));
1489
- if (ct.kind === "single") {
1490
- const { rows: existing } = await pool_default.query(`SELECT id FROM ${quotedTableName} LIMIT 1`);
1491
- if (existing[0]) {
1492
- const setClauses = fields.map((f, i) => f.type === "media-gallery" || f.type === "array" ? `${quoteIdentifier(f.name)} = $${i + 1}::jsonb` : `${quoteIdentifier(f.name)} = $${i + 1}`).join(", ");
1493
- const extraClauses = [];
1494
- const extraValues2 = [];
1495
- if (req.body.localized !== void 0) {
1496
- extraClauses.push(`localized = $${fields.length + 1}::jsonb`);
1497
- extraValues2.push(JSON.stringify(req.body.localized));
1498
- }
1499
- const allClauses = [setClauses, ...extraClauses].filter(Boolean).join(", ");
1500
- const values2 = [
1501
- ...fields.map((f) => {
1502
- const v = req.body[f.name];
1503
- return f.type === "media-gallery" || f.type === "array" ? JSON.stringify(v) : v;
1504
- }),
1505
- ...extraValues2,
1506
- existing[0].id
1507
- ];
1508
- const updateSql = fields.length + extraValues2.length > 0 ? `UPDATE ${quotedTableName} SET ${allClauses}, updated_at = NOW() WHERE id = $${fields.length + extraValues2.length + 1} RETURNING *` : `UPDATE ${quotedTableName} SET updated_at = NOW() WHERE id = $1 RETURNING *`;
1509
- const updateValues = fields.length + extraValues2.length > 0 ? values2 : [existing[0].id];
1510
- const { rows: rows2 } = await pool_default.query(updateSql, updateValues);
1511
- await Promise.all(mmFields.map((f) => {
1512
- const ids = Array.isArray(req.body[f.name]) ? req.body[f.name] : [];
1513
- return syncManyToMany(existing[0].id, ct.tableName, f, ids);
1514
- }));
1515
- res.json(rows2[0]);
1516
- return;
1517
- }
1518
- }
1519
- const id = createId();
1520
- const userId = req.user?.id ?? null;
1521
- const extraCols = [];
1522
- const extraPlaceholders = [];
1523
- const extraValues = [];
1524
- if (req.body.localized !== void 0) {
1525
- extraCols.push("localized");
1526
- extraPlaceholders.push(`$${3 + fields.length}::jsonb`);
1527
- extraValues.push(JSON.stringify(req.body.localized));
1528
- }
1529
- const cols = ["id", "created_by", ...fields.map((f) => f.name), ...extraCols].map((col) => quoteIdentifier(col)).join(", ");
1530
- const placeholders = [
1531
- "$1",
1532
- "$2",
1533
- ...fields.map((f, i) => f.type === "media-gallery" || f.type === "array" ? `$${i + 3}::jsonb` : `$${i + 3}`),
1534
- ...extraPlaceholders
1535
- ].join(", ");
1536
- const values = [
1537
- id,
1538
- userId,
1539
- ...fields.map((f) => {
1540
- const v = req.body[f.name];
1541
- return f.type === "media-gallery" || f.type === "array" ? JSON.stringify(v) : v;
1542
- }),
1543
- ...extraValues
1544
- ];
1545
- const { rows } = await pool_default.query(`INSERT INTO ${quotedTableName} (${cols}) VALUES (${placeholders}) RETURNING *`, values);
1546
- await Promise.all(mmFields.map((f) => {
1547
- const ids = Array.isArray(req.body[f.name]) ? req.body[f.name] : [];
1548
- return syncManyToMany(id, ct.tableName, f, ids);
1549
- }));
1550
- res.status(201).json(rows[0]);
1551
- triggerWebhooks("entry.created", { content_type: req.params.slug, entry_id: rows[0].id });
1552
- };
1553
- var getSingleEntry = async (req, res) => {
1554
- const ct = await findContentTypeBySlug(req.params.slug);
1555
- if (!ct) {
1556
- res.status(404).json({ error: "Content type not found" });
1557
- return;
1558
- }
1559
- if (ct.kind !== "single") {
1560
- res.status(400).json({ error: "Content type is not a Single Type" });
1561
- return;
1562
- }
1563
- assertSafeIdentifier(ct.tableName);
1564
- const quotedTableName = quoteIdentifier(ct.tableName);
1565
- const { rows } = await pool_default.query(`SELECT * FROM ${quotedTableName} LIMIT 1`);
1566
- if (!rows[0]) {
1567
- res.status(404).json({ error: "No entry found" });
1568
- return;
1569
- }
1570
- const locale = req.query.locale ? String(req.query.locale) : void 0;
1571
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
1572
- const mmIds = await loadManyToManyIds(rows[0].id, ct.tableName, ct.fields);
1573
- const provider = await getProvider();
1574
- const resolved = resolveLocalizedRow(rows[0], ct, locale, fallbacks);
1575
- const key = resolved._author_avatar_url;
1576
- if (key && !key.startsWith("http"))
1577
- resolved._author_avatar_url = await provider.getUrl(key);
1578
- res.json({ ...resolved, ...mmIds });
1579
- };
1580
- var updateEntry = async (req, res) => {
1581
- const ct = await findContentTypeBySlug(req.params.slug);
1582
- if (!ct) {
1583
- res.status(404).json({ error: "Content type not found" });
1584
- return;
1585
- }
1586
- validate(ct, req.body);
1587
- assertSafeIdentifier(ct.tableName);
1588
- const quotedTableName = quoteIdentifier(ct.tableName);
1589
- const isUser = await isUserRole(req.user?.roleId);
1590
- if (isUser && ct.kind === "single") {
1591
- res.status(403).json({ error: "Single types are read-only for User role" });
1592
- return;
1593
- }
1594
- if (isUser && ct.kind === "collection") {
1595
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
1596
- if (!authorRows[0]) {
1597
- res.status(404).json({ error: "Entry not found" });
1598
- return;
1599
- }
1600
- if (authorRows[0].created_by !== req.user?.id) {
1601
- res.status(403).json({ error: "Forbidden" });
1602
- return;
1603
- }
1604
- }
1605
- const mmFields = ct.fields.filter((f) => f.type === "relation" && (f.relationType ?? "many-to-one") === "many-to-many" && req.body[f.name] !== void 0);
1606
- const fields = ct.fields.filter((f) => req.body[f.name] !== void 0 && !isVirtualRelation(f));
1607
- fields.forEach((f) => assertSafeIdentifier(f.name));
1608
- const setClauses = fields.map((f, i) => f.type === "media-gallery" || f.type === "array" ? `${quoteIdentifier(f.name)} = $${i + 1}::jsonb` : `${quoteIdentifier(f.name)} = $${i + 1}`).join(", ");
1609
- const extraClauses = [];
1610
- const extraValues = [];
1611
- if (req.body.localized !== void 0) {
1612
- extraClauses.push(`localized = $${fields.length + 1}::jsonb`);
1613
- extraValues.push(JSON.stringify(req.body.localized));
1614
- }
1615
- const allClauses = [setClauses, ...extraClauses].filter(Boolean).join(", ");
1616
- const values = [
1617
- ...fields.map((f) => {
1618
- const v = req.body[f.name];
1619
- return f.type === "media-gallery" || f.type === "array" ? JSON.stringify(v) : v;
1620
- }),
1621
- ...extraValues,
1622
- req.params.id
1623
- ];
1624
- const updateSql = fields.length + extraValues.length > 0 ? `UPDATE ${quotedTableName} SET ${allClauses}, updated_at = NOW() WHERE id = $${fields.length + extraValues.length + 1} RETURNING *` : `UPDATE ${quotedTableName} SET updated_at = NOW() WHERE id = $1 RETURNING *`;
1625
- const updateValues = fields.length + extraValues.length > 0 ? values : [req.params.id];
1626
- const { rows } = await pool_default.query(updateSql, updateValues);
1627
- if (!rows[0]) {
1628
- res.status(404).json({ error: "Entry not found" });
1629
- return;
1630
- }
1631
- await Promise.all(mmFields.map((f) => {
1632
- const ids = Array.isArray(req.body[f.name]) ? req.body[f.name] : [];
1633
- return syncManyToMany(req.params.id, ct.tableName, f, ids);
1634
- }));
1635
- res.json(rows[0]);
1636
- triggerWebhooks("entry.updated", { content_type: req.params.slug, entry_id: req.params.id });
1637
- };
1638
- var SNAPSHOT_EXCLUDED = [
1639
- "'id'",
1640
- "'status'",
1641
- "'published_data'",
1642
- "'published_at'",
1643
- "'scheduled_for'",
1644
- "'created_at'",
1645
- "'updated_at'"
1646
- ];
1647
- function buildSnapshotExpr(tableName) {
1648
- const strip = SNAPSHOT_EXCLUDED.reduce((expr, col) => `${expr} - ${col}`, `to_jsonb(t.*)`);
1649
- return `(SELECT ${strip} FROM ${quoteIdentifier(tableName)} t WHERE t.id = $1)`;
1650
- }
1651
- var patchEntryStatus = async (req, res) => {
1652
- const { status, scheduled_for } = req.body;
1653
- if (status !== "draft" && status !== "published" && status !== "scheduled") {
1654
- res.status(400).json({ error: "status must be draft, published, or scheduled" });
1655
- return;
1656
- }
1657
- if (status === "scheduled") {
1658
- if (!scheduled_for || typeof scheduled_for !== "string" || isNaN(Date.parse(scheduled_for))) {
1659
- res.status(400).json({ error: "scheduled_for must be a valid ISO date string" });
1660
- return;
1661
- }
1662
- if (new Date(scheduled_for) <= /* @__PURE__ */ new Date()) {
1663
- res.status(400).json({ error: "scheduled_for must be in the future" });
1664
- return;
1665
- }
1666
- }
1667
- const ct = await findContentTypeBySlug(req.params.slug);
1668
- if (!ct) {
1669
- res.status(404).json({ error: "Content type not found" });
1670
- return;
1671
- }
1672
- assertSafeIdentifier(ct.tableName);
1673
- const quotedTableName = quoteIdentifier(ct.tableName);
1674
- const isUser = await isUserRole(req.user?.roleId);
1675
- if (isUser && ct.kind === "single") {
1676
- res.status(403).json({ error: "Single types are read-only for User role" });
1677
- return;
1678
- }
1679
- if (isUser && ct.kind === "collection") {
1680
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
1681
- if (!authorRows[0]) {
1682
- res.status(404).json({ error: "Entry not found" });
1683
- return;
1684
- }
1685
- if (authorRows[0].created_by !== req.user?.id) {
1686
- res.status(403).json({ error: "Forbidden" });
1687
- return;
1688
- }
1689
- }
1690
- let sql;
1691
- let values;
1692
- if (status === "published") {
1693
- sql = `
1694
- UPDATE ${quotedTableName} SET
1695
- status = 'published',
1696
- published_data = ${buildSnapshotExpr(ct.tableName)},
1697
- published_at = NOW(),
1698
- scheduled_for = NULL,
1699
- updated_at = NOW()
1700
- WHERE id = $1
1701
- RETURNING *
1702
- `;
1703
- values = [req.params.id];
1704
- } else if (status === "scheduled") {
1705
- sql = `
1706
- UPDATE ${quotedTableName} SET
1707
- status = 'scheduled',
1708
- scheduled_for = $2,
1709
- updated_at = NOW()
1710
- WHERE id = $1
1711
- RETURNING *
1712
- `;
1713
- values = [req.params.id, scheduled_for];
1714
- } else {
1715
- sql = `
1716
- UPDATE ${quotedTableName} SET
1717
- status = 'draft',
1718
- published_data = NULL,
1719
- published_at = NULL,
1720
- scheduled_for = NULL,
1721
- updated_at = NOW()
1722
- WHERE id = $1
1723
- RETURNING *
1724
- `;
1725
- values = [req.params.id];
1726
- }
1727
- const { rows } = await pool_default.query(sql, values);
1728
- if (!rows[0]) {
1729
- res.status(404).json({ error: "Entry not found" });
1730
- return;
1731
- }
1732
- res.json(rows[0]);
1733
- const webhookEvent = status === "published" ? "entry.published" : status === "draft" ? "entry.unpublished" : null;
1734
- if (webhookEvent)
1735
- triggerWebhooks(webhookEvent, { content_type: req.params.slug, entry_id: req.params.id });
1736
- };
1737
- var deleteEntry = async (req, res) => {
1738
- const ct = await findContentTypeBySlug(req.params.slug);
1739
- if (!ct) {
1740
- res.status(404).json({ error: "Content type not found" });
1741
- return;
1742
- }
1743
- assertSafeIdentifier(ct.tableName);
1744
- const quotedTableName = quoteIdentifier(ct.tableName);
1745
- const isUser = await isUserRole(req.user?.roleId);
1746
- if (isUser && ct.kind === "single") {
1747
- res.status(403).json({ error: "Single types are read-only for User role" });
1748
- return;
1749
- }
1750
- if (isUser && ct.kind === "collection") {
1751
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
1752
- if (!authorRows[0]) {
1753
- res.status(404).json({ error: "Entry not found" });
1754
- return;
1755
- }
1756
- if (authorRows[0].created_by !== req.user?.id) {
1757
- res.status(403).json({ error: "Forbidden" });
1758
- return;
1759
- }
1760
- }
1761
- const { rowCount } = await pool_default.query(`DELETE FROM ${quotedTableName} WHERE id = $1`, [
1762
- req.params.id
1763
- ]);
1764
- if (!rowCount) {
1765
- res.status(404).json({ error: "Entry not found" });
1766
- return;
1767
- }
1768
- res.status(204).end();
1769
- triggerWebhooks("entry.deleted", { content_type: req.params.slug, entry_id: req.params.id });
1770
- };
1771
-
1772
- // ../core/dist/controllers/users.js
1773
- import bcrypt2 from "bcryptjs";
1774
- import { z as z4, flattenError as flattenError4 } from "zod";
1775
- var CreateUserSchema = z4.object({
1776
- email: z4.email(),
1777
- password: z4.string().min(8),
1778
- roleId: z4.string().min(1)
1779
- });
1780
- var UpdateUserSchema = z4.object({
1781
- email: z4.email().optional(),
1782
- roleId: z4.string().min(1).optional(),
1783
- firstName: z4.string().max(100).nullable().optional(),
1784
- lastName: z4.string().max(100).nullable().optional()
1785
- });
1786
- var ChangePasswordSchema = z4.object({
1787
- currentPassword: z4.string().min(1),
1788
- newPassword: z4.string().min(8)
1789
- });
1790
- var UpdateMeSchema = z4.object({
1791
- firstName: z4.string().max(100).optional(),
1792
- lastName: z4.string().max(100).optional(),
1793
- jobTitle: z4.string().max(100).optional(),
1794
- organization: z4.string().max(150).optional(),
1795
- country: z4.string().max(100).optional()
1796
- });
1797
- async function resolveAvatarUrl(row) {
1798
- if (!row.avatar_url || row.avatar_url.startsWith("http"))
1799
- return row;
1800
- const provider = await getProvider();
1801
- return { ...row, avatar_url: await provider.getUrl(row.avatar_url) };
1802
- }
1803
- async function roleNameById(roleId) {
1804
- const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
1805
- return rows[0]?.name ?? null;
1806
- }
1807
- async function listUsers(_req, res) {
1808
- const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, r.name as role_name, u.first_name, u.last_name, u.created_at
1809
- FROM plank_users u
1810
- JOIN plank_roles r ON r.id = u.role_id
1811
- ORDER BY u.created_at DESC`);
1812
- res.json(rows);
1813
- }
1814
- async function getMe(req, res) {
1815
- const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, u.first_name, u.last_name, u.avatar_url,
1816
- u.job_title, u.organization, u.country, u.created_at,
1817
- r.permissions
1818
- FROM plank_users u
1819
- JOIN plank_roles r ON r.id = u.role_id
1820
- WHERE u.id = $1`, [req.user.id]);
1821
- if (!rows[0]) {
1822
- res.status(404).json({ error: "User not found" });
1823
- return;
1824
- }
1825
- const resolved = await resolveAvatarUrl(rows[0]);
1826
- res.json({ ...resolved, permissions: rows[0].permissions });
1827
- }
1828
- async function updateMe(req, res) {
1829
- const parsed = UpdateMeSchema.safeParse(req.body);
1830
- if (!parsed.success) {
1831
- res.status(400).json({ errors: flattenError4(parsed.error, (i) => i.message) });
1832
- return;
1833
- }
1834
- const { firstName, lastName, jobTitle, organization, country } = parsed.data;
1835
- const { rows } = await pool_default.query(`UPDATE plank_users
1836
- SET first_name = COALESCE($1, first_name),
1837
- last_name = COALESCE($2, last_name),
1838
- job_title = COALESCE($3, job_title),
1839
- organization = COALESCE($4, organization),
1840
- country = COALESCE($5, country)
1841
- WHERE id = $6
1842
- RETURNING id, email, role_id, first_name, last_name, avatar_url,
1843
- job_title, organization, country, created_at`, [firstName ?? null, lastName ?? null, jobTitle ?? null, organization ?? null, country ?? null, req.user.id]);
1844
- if (!rows[0]) {
1845
- res.status(404).json({ error: "User not found" });
1846
- return;
1847
- }
1848
- res.json(await resolveAvatarUrl(rows[0]));
1849
- }
1850
- async function uploadAvatar(req, res) {
1851
- if (!req.file) {
1852
- res.status(400).json({ error: "No file provided" });
1853
- return;
1854
- }
1855
- const provider = await getProvider();
1856
- const { key } = await provider.upload(req.file, { prefix: "avatars" });
1857
- const { rows } = await pool_default.query(`UPDATE plank_users SET avatar_url = $1 WHERE id = $2
1858
- RETURNING id, email, role_id, first_name, last_name, avatar_url, created_at`, [key, req.user.id]);
1859
- const avatarUrl = await provider.getUrl(key);
1860
- res.json({ avatarUrl, user: await resolveAvatarUrl(rows[0]) });
1861
- }
1862
- async function presignAvatar(req, res) {
1863
- const { filename, mimeType } = req.body;
1864
- if (!filename || !mimeType) {
1865
- res.status(400).json({ error: "filename and mimeType are required" });
1866
- return;
1867
- }
1868
- const provider = await getProvider();
1869
- if (!provider.presign) {
1870
- res.json({ mode: "direct" });
1871
- return;
1872
- }
1873
- const { key, uploadUrl } = await provider.presign(filename, mimeType, { prefix: "avatars" });
1874
- res.json({ mode: "presigned", key, uploadUrl });
1875
- }
1876
- async function confirmAvatar(req, res) {
1877
- const { key } = req.body;
1878
- if (!key) {
1879
- res.status(400).json({ error: "key is required" });
1880
- return;
1881
- }
1882
- const provider = await getProvider();
1883
- const { rows } = await pool_default.query(`UPDATE plank_users SET avatar_url = $1 WHERE id = $2
1884
- RETURNING id, email, role_id, first_name, last_name, avatar_url, created_at`, [key, req.user.id]);
1885
- const avatarUrl = await provider.getUrl(key);
1886
- res.json({ avatarUrl, user: await resolveAvatarUrl(rows[0]) });
1887
- }
1888
- async function deleteAvatar(req, res) {
1889
- const { rows } = await pool_default.query("SELECT avatar_url FROM plank_users WHERE id = $1", [req.user.id]);
1890
- const current = rows[0]?.avatar_url;
1891
- if (current && !current.startsWith("http")) {
1892
- const provider = await getProvider();
1893
- await provider.delete(current).catch(() => {
1894
- });
1895
- }
1896
- await pool_default.query("UPDATE plank_users SET avatar_url = NULL WHERE id = $1", [req.user.id]);
1897
- res.status(204).end();
1898
- }
1899
- async function changePassword(req, res) {
1900
- const parsed = ChangePasswordSchema.safeParse(req.body);
1901
- if (!parsed.success) {
1902
- res.status(400).json({ errors: flattenError4(parsed.error, (i) => i.message) });
1903
- return;
1904
- }
1905
- const { rows } = await pool_default.query("SELECT password FROM plank_users WHERE id = $1", [req.user.id]);
1906
- if (!rows[0]) {
1907
- res.status(404).json({ error: "User not found" });
1908
- return;
1909
- }
1910
- const valid = await bcrypt2.compare(parsed.data.currentPassword, rows[0].password);
1911
- if (!valid) {
1912
- res.status(400).json({ error: "Current password is incorrect" });
1913
- return;
1914
- }
1915
- const hashed = await bcrypt2.hash(parsed.data.newPassword, 12);
1916
- await pool_default.query("UPDATE plank_users SET password = $1 WHERE id = $2", [hashed, req.user.id]);
1917
- res.status(204).end();
1918
- }
1919
- async function createUser(req, res) {
1920
- const parsed = CreateUserSchema.safeParse(req.body);
1921
- if (!parsed.success) {
1922
- res.status(400).json({ errors: flattenError4(parsed.error, (i) => i.message) });
1923
- return;
1924
- }
1925
- const { email, password, roleId } = parsed.data;
1926
- const requesterRoleName = await roleNameById(req.user.roleId);
1927
- const targetRoleName = await roleNameById(roleId);
1928
- if (targetRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
1929
- res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
1930
- return;
1931
- }
1932
- const hashed = await bcrypt2.hash(password, 12);
1933
- const id = createId();
1934
- await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, roleId]);
1935
- res.status(201).json({ id, email, roleId });
1936
- }
1937
- async function updateUser(req, res) {
1938
- const parsed = UpdateUserSchema.safeParse(req.body);
1939
- if (!parsed.success) {
1940
- res.status(400).json({ errors: flattenError4(parsed.error, (i) => i.message) });
1941
- return;
1942
- }
1943
- const { email, roleId, firstName, lastName } = parsed.data;
1944
- const requesterRoleName = await roleNameById(req.user.roleId);
1945
- const { rows: targetRows } = await pool_default.query("SELECT role_id FROM plank_users WHERE id = $1", [req.params.id]);
1946
- if (!targetRows[0]) {
1947
- res.status(404).json({ error: "User not found" });
1948
- return;
1949
- }
1950
- const targetCurrentRoleName = await roleNameById(targetRows[0].role_id);
1951
- if (targetCurrentRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
1952
- res.status(403).json({ error: "Only Super Admin can edit Super Admin users" });
1953
- return;
1954
- }
1955
- if (roleId) {
1956
- const nextRoleName = await roleNameById(roleId);
1957
- if (nextRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
1958
- res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
1959
- return;
1960
- }
1961
- }
1962
- const { rows } = await pool_default.query(`UPDATE plank_users
1963
- SET email = COALESCE($1, email),
1964
- role_id = COALESCE($2, role_id),
1965
- first_name = COALESCE($3, first_name),
1966
- last_name = COALESCE($4, last_name)
1967
- WHERE id = $5
1968
- RETURNING id, email, role_id, first_name, last_name, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, req.params.id]);
1969
- if (!rows[0]) {
1970
- res.status(404).json({ error: "User not found" });
1971
- return;
1972
- }
1973
- res.json(rows[0]);
1974
- }
1975
- async function deleteUser(req, res) {
1976
- if (req.params.id === req.user.id) {
1977
- res.status(403).json({ error: "You cannot delete your own account" });
1978
- return;
1979
- }
1980
- const { rows } = await pool_default.query("SELECT role_id FROM plank_users WHERE id = $1", [req.params.id]);
1981
- if (!rows[0]) {
1982
- res.status(404).json({ error: "User not found" });
1983
- return;
1984
- }
1985
- const { rows: roleRows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [rows[0].role_id]);
1986
- if (roleRows[0]?.name === "Super Admin") {
1987
- res.status(403).json({ error: "Super Admin users cannot be deleted" });
1988
- return;
1989
- }
1990
- await pool_default.query("DELETE FROM plank_users WHERE id = $1", [req.params.id]);
1991
- res.status(204).end();
1992
- }
1993
-
1994
- // ../core/dist/controllers/userPrefs.js
1995
- import { z as z5 } from "zod";
1996
- var SetPrefSchema = z5.object({ value: z5.unknown() });
1997
- async function getUserPref(req, res) {
1998
- const { rows } = await pool_default.query("SELECT value FROM plank_user_prefs WHERE user_id = $1 AND key = $2", [req.user.id, req.params.key]);
1999
- res.json({ value: rows[0] ? JSON.parse(rows[0].value) : null });
2000
- }
2001
- async function setUserPref(req, res) {
2002
- const parsed = SetPrefSchema.safeParse(req.body);
2003
- if (!parsed.success) {
2004
- res.status(400).json({ error: "Invalid body" });
2005
- return;
2006
- }
2007
- await pool_default.query(`INSERT INTO plank_user_prefs (user_id, key, value, updated_at)
2008
- VALUES ($1, $2, $3, NOW())
2009
- ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, [req.user.id, req.params.key, JSON.stringify(parsed.data.value)]);
2010
- res.status(204).end();
2011
- }
2012
-
2013
- // ../core/dist/controllers/roles.js
2014
- import { z as z6 } from "zod";
2015
- async function listRoles(_req, res) {
2016
- const { rows } = await pool_default.query("SELECT id, name, permissions FROM plank_roles ORDER BY name ASC");
2017
- res.json(rows);
2018
- }
2019
- async function updateRole(req, res) {
2020
- const { rows: target } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [req.params.id]);
2021
- if (!target[0]) {
2022
- res.status(404).json({ error: "Role not found" });
2023
- return;
2024
- }
2025
- if (target[0].name === "Super Admin") {
2026
- res.status(403).json({ error: "Super Admin permissions cannot be modified" });
2027
- return;
2028
- }
2029
- const parsed = z6.object({ permissions: z6.array(z6.string()) }).safeParse(req.body);
2030
- if (!parsed.success) {
2031
- res.status(400).json({ error: "Invalid permissions" });
2032
- return;
2033
- }
2034
- const { rows } = await pool_default.query("UPDATE plank_roles SET permissions = $1 WHERE id = $2 RETURNING id, name, permissions", [JSON.stringify(parsed.data.permissions), req.params.id]);
2035
- res.json(rows[0]);
2036
- }
2037
- async function resetRoles(_req, res) {
2038
- for (const [name, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
2039
- if (name === "Super Admin")
2040
- continue;
2041
- await pool_default.query("UPDATE plank_roles SET permissions = $1 WHERE name = $2", [JSON.stringify(permissions), name]);
2042
- }
2043
- const { rows } = await pool_default.query("SELECT id, name, permissions FROM plank_roles ORDER BY name ASC");
2044
- res.json(rows);
2045
- }
2046
-
2047
- // ../core/dist/controllers/apiTokens.js
2048
- import { randomBytes as randomBytes5, createHash } from "crypto";
2049
- import { z as z7, flattenError as flattenError5 } from "zod";
2050
- var CreateTokenSchema = z7.object({
2051
- name: z7.string().min(1),
2052
- accessType: z7.enum(["read-only", "full-access"])
2053
- });
2054
- function hashToken(token) {
2055
- return createHash("sha256").update(token).digest("hex");
2056
- }
2057
- async function listApiTokens(_req, res) {
2058
- const { rows } = await pool_default.query("SELECT id, name, access_type, created_at FROM plank_api_tokens ORDER BY created_at DESC");
2059
- res.json(rows);
2060
- }
2061
- async function createApiToken(req, res) {
2062
- const parsed = CreateTokenSchema.safeParse(req.body);
2063
- if (!parsed.success) {
2064
- res.status(400).json({ errors: flattenError5(parsed.error, (i) => i.message) });
2065
- return;
2066
- }
2067
- const { name, accessType } = parsed.data;
2068
- const id = createId();
2069
- const token = `plank_${randomBytes5(32).toString("hex")}`;
2070
- const hashed = hashToken(token);
2071
- 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]);
2072
- res.status(201).json({ id, name, accessType, token });
2073
- }
2074
- async function deleteApiToken(req, res) {
2075
- const { rowCount } = await pool_default.query("DELETE FROM plank_api_tokens WHERE id = $1", [req.params.id]);
2076
- if (!rowCount) {
2077
- res.status(404).json({ error: "API token not found" });
2078
- return;
2079
- }
2080
- res.status(204).end();
2081
- }
2082
-
2083
- // ../core/dist/controllers/media.js
2084
- import { randomBytes as randomBytes6 } from "crypto";
2085
- var MEDIA_PREFIX = "media";
2086
- async function listMedia(req, res) {
2087
- const page = Math.max(1, parseInt(req.query.page) || 1);
2088
- const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 24));
2089
- const offset = (page - 1) * limit;
2090
- const folderId = req.query.folder_id || null;
2091
- const { rows } = await pool_default.query(`SELECT *, COUNT(*) OVER() AS total
2092
- FROM plank_media
2093
- WHERE folder_id IS NOT DISTINCT FROM $3
2094
- ORDER BY created_at DESC
2095
- LIMIT $1 OFFSET $2`, [limit, offset, folderId]);
2096
- const provider = await getProvider();
2097
- const items = await Promise.all(rows.map(async (r) => ({
2098
- id: r.id,
2099
- filename: r.filename,
2100
- url: await provider.getUrl(r.provider_key),
2101
- mime_type: r.mime_type,
2102
- size: r.size,
2103
- alt: r.alt,
2104
- width: r.width,
2105
- height: r.height,
2106
- folder_id: r.folder_id,
2107
- uploaded_by: r.uploaded_by,
2108
- created_at: r.created_at
2109
- })));
2110
- const total = rows[0] ? parseInt(rows[0].total) : 0;
2111
- res.json({ items, total, page, limit, pages: Math.ceil(total / limit) });
2112
- }
2113
- async function uploadMedia(req, res) {
2114
- const files = req.files ?? [];
2115
- if (files.length === 0) {
2116
- res.status(400).json({ error: "No file provided" });
2117
- return;
2118
- }
2119
- const folderId = req.body.folder_id || null;
2120
- const isBundle = req.body.bundle === "true";
2121
- const provider = await getProvider();
2122
- if (folderId) {
2123
- const { rows } = await pool_default.query("SELECT id FROM plank_folders WHERE id = $1", [folderId]);
2124
- if (!rows[0]) {
2125
- res.status(404).json({ error: "Folder not found" });
2126
- return;
2127
- }
2128
- }
2129
- if (isBundle) {
2130
- const m3u8File = files.find((f) => f.originalname.endsWith(".m3u8"));
2131
- if (!m3u8File) {
2132
- res.status(400).json({ error: "No .m3u8 file found in bundle" });
2133
- return;
2134
- }
2135
- const bundleId = randomBytes6(8).toString("hex");
2136
- const prefix = [MEDIA_PREFIX, folderId, bundleId].filter(Boolean).join("/");
2137
- const rootDir = m3u8File.originalname.includes("/") ? m3u8File.originalname.split("/")[0] : null;
2138
- const stripRoot = (path) => rootDir && path.startsWith(`${rootDir}/`) ? path.slice(rootDir.length + 1) : path;
2139
- await Promise.all(files.map((file2) => {
2140
- const relativePath = stripRoot(file2.originalname);
2141
- const exactKey = `${prefix}/${relativePath}`;
2142
- return provider.uploadRaw(file2.buffer, exactKey, file2.mimetype);
2143
- }));
2144
- const m3u8RelPath = stripRoot(m3u8File.originalname);
2145
- const m3u8Key = `${prefix}/${m3u8RelPath}`;
2146
- const m3u8Url = await provider.getUrl(m3u8Key);
2147
- const id2 = createId();
2148
- const filename = m3u8File.originalname.split("/").pop() ?? m3u8File.originalname;
2149
- await pool_default.query(`INSERT INTO plank_media (id, filename, url, provider_key, mime_type, size, folder_id, uploaded_by)
2150
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id2, filename, m3u8Url, m3u8Key, m3u8File.mimetype, m3u8File.size, folderId, req.user.id]);
2151
- res.status(201).json({ id: id2, url: m3u8Url, filename });
2152
- return;
2153
- }
2154
- const file = files[0];
2155
- const { url, key } = await provider.upload(file, { prefix: folderId ? `${MEDIA_PREFIX}/${folderId}` : MEDIA_PREFIX });
2156
- const id = createId();
2157
- const width = req.body.width ? parseInt(req.body.width) : null;
2158
- const height = req.body.height ? parseInt(req.body.height) : null;
2159
- await pool_default.query(`INSERT INTO plank_media (id, filename, url, provider_key, mime_type, size, alt, width, height, folder_id, uploaded_by)
2160
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [id, file.originalname, url, key, file.mimetype, file.size, null, width, height, folderId, req.user.id]);
2161
- const resolvedUrl = await provider.getUrl(key);
2162
- res.status(201).json({ id, url: resolvedUrl, filename: file.originalname, alt: null, width, height });
2163
- }
2164
- async function deleteMedia(req, res) {
2165
- const { id } = req.params;
2166
- const { rows } = await pool_default.query("SELECT * FROM plank_media WHERE id = $1", [id]);
2167
- if (!rows[0]) {
2168
- res.status(404).json({ error: "Media not found" });
2169
- return;
2170
- }
2171
- const provider = await getProvider();
2172
- await provider.delete(rows[0].provider_key);
2173
- await pool_default.query("DELETE FROM plank_media WHERE id = $1", [id]);
2174
- res.status(204).end();
2175
- }
2176
- async function presignMedia(req, res) {
2177
- const { filename, mimeType, folderId } = req.body;
2178
- if (!filename || !mimeType) {
2179
- res.status(400).json({ error: "filename and mimeType are required" });
2180
- return;
2181
- }
2182
- const provider = await getProvider();
2183
- if (!provider.presign) {
2184
- res.json({ mode: "direct" });
2185
- return;
2186
- }
2187
- if (folderId) {
2188
- const { rows } = await pool_default.query("SELECT id FROM plank_folders WHERE id = $1", [folderId]);
2189
- if (!rows[0]) {
2190
- res.status(404).json({ error: "Folder not found" });
2191
- return;
2192
- }
2193
- }
2194
- const prefix = folderId ? `${MEDIA_PREFIX}/${folderId}` : MEDIA_PREFIX;
2195
- const result = await provider.presign(filename, mimeType, { prefix });
2196
- res.json({ mode: "presigned", ...result });
2197
- }
2198
- async function confirmMedia(req, res) {
2199
- const { key, filename, mimeType, size, folderId, width, height } = req.body;
2200
- if (!key || !filename || !mimeType) {
2201
- res.status(400).json({ error: "key, filename and mimeType are required" });
2202
- return;
2203
- }
2204
- const provider = await getProvider();
2205
- const url = await provider.getUrl(key);
2206
- const id = createId();
2207
- await pool_default.query(`INSERT INTO plank_media (id, filename, url, provider_key, mime_type, size, alt, width, height, folder_id, uploaded_by)
2208
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [id, filename, url, key, mimeType, size ?? null, null, width ?? null, height ?? null, folderId ?? null, req.user.id]);
2209
- res.status(201).json({ id, url, filename, alt: null, width: width ?? null, height: height ?? null });
2210
- }
2211
- async function updateMedia(req, res) {
2212
- const { id } = req.params;
2213
- const { filename, alt } = req.body;
2214
- const { rows } = await pool_default.query(`UPDATE plank_media
2215
- SET filename = COALESCE($1, filename),
2216
- alt = $2
2217
- WHERE id = $3
2218
- RETURNING *`, [filename ?? null, alt ?? null, id]);
2219
- if (!rows[0]) {
2220
- res.status(404).json({ error: "Media not found" });
2221
- return;
2222
- }
2223
- const provider = await getProvider();
2224
- const url = await provider.getUrl(rows[0].provider_key);
2225
- res.json({ ...rows[0], url });
2226
- }
2227
- async function getMediaUrl(req, res) {
2228
- const { id } = req.params;
2229
- const { rows } = await pool_default.query("SELECT * FROM plank_media WHERE id = $1", [id]);
2230
- if (!rows[0]) {
2231
- res.status(404).json({ error: "Media not found" });
2232
- return;
2233
- }
2234
- const provider = await getProvider();
2235
- const url = await provider.getUrl(rows[0].provider_key);
2236
- res.json({ id, url });
2237
- }
2238
-
2239
- // ../core/dist/controllers/folders.js
2240
- async function listFolders(req, res) {
2241
- const parentId = req.query.parent_id || null;
2242
- const { rows } = await pool_default.query(`SELECT f.*,
2243
- (
2244
- (SELECT COUNT(*) FROM plank_folders sub WHERE sub.parent_id = f.id) +
2245
- (SELECT COUNT(*) FROM plank_media m WHERE m.folder_id = f.id)
2246
- )::int AS item_count
2247
- FROM plank_folders f
2248
- WHERE f.parent_id IS NOT DISTINCT FROM $1
2249
- ORDER BY f.name ASC`, [parentId]);
2250
- res.json({ folders: rows });
2251
- }
2252
- async function createFolder(req, res) {
2253
- const { name, parent_id } = req.body;
2254
- if (!name?.trim()) {
2255
- res.status(400).json({ error: "name is required" });
2256
- return;
2257
- }
2258
- if (parent_id) {
2259
- const { rows: rows2 } = await pool_default.query("SELECT id FROM plank_folders WHERE id = $1", [parent_id]);
2260
- if (!rows2[0]) {
2261
- res.status(404).json({ error: "Parent folder not found" });
2262
- return;
2263
- }
2264
- }
2265
- const id = createId();
2266
- const { rows } = await pool_default.query(`INSERT INTO plank_folders (id, name, parent_id) VALUES ($1, $2, $3) RETURNING *`, [id, name.trim(), parent_id ?? null]);
2267
- res.status(201).json(rows[0]);
2268
- }
2269
- async function renameFolder(req, res) {
2270
- const { id } = req.params;
2271
- const { name } = req.body;
2272
- if (!name?.trim()) {
2273
- res.status(400).json({ error: "name is required" });
2274
- return;
2275
- }
2276
- const { rows } = await pool_default.query(`UPDATE plank_folders SET name = $1 WHERE id = $2 RETURNING *`, [name.trim(), id]);
2277
- if (!rows[0]) {
2278
- res.status(404).json({ error: "Folder not found" });
2279
- return;
2280
- }
2281
- res.json(rows[0]);
2282
- }
2283
- async function deleteFolder(req, res) {
2284
- const { id } = req.params;
2285
- const { rows: folderRows } = await pool_default.query("SELECT id FROM plank_folders WHERE id = $1", [id]);
2286
- if (!folderRows[0]) {
2287
- res.status(404).json({ error: "Folder not found" });
2288
- return;
2289
- }
2290
- const { rows: subfolders } = await pool_default.query("SELECT id FROM plank_folders WHERE parent_id = $1 LIMIT 1", [id]);
2291
- const { rows: mediaItems } = await pool_default.query("SELECT id FROM plank_media WHERE folder_id = $1 LIMIT 1", [id]);
2292
- if (subfolders.length > 0 || mediaItems.length > 0) {
2293
- res.status(409).json({ error: "Folder is not empty. Move or delete its contents first." });
2294
- return;
2295
- }
2296
- await pool_default.query("DELETE FROM plank_folders WHERE id = $1", [id]);
2297
- res.status(204).end();
2298
- }
2299
-
2300
- // ../core/dist/controllers/settings.js
2301
- var SENSITIVE_FIELDS2 = {
2302
- media: /* @__PURE__ */ new Set(["s3.secret_access_key", "r2.secret_access_key"])
2303
- };
2304
- var MASKED = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
2305
- function maskSettings(namespace, settings) {
2306
- const sensitive = SENSITIVE_FIELDS2[namespace];
2307
- if (!sensitive)
2308
- return settings;
2309
- return Object.fromEntries(Object.entries(settings).map(([k, v]) => [k, sensitive.has(k) && v ? MASKED : v]));
2310
- }
2311
- async function getNamespaceSettings(req, res) {
2312
- const { namespace } = req.params;
2313
- const settings = await getSettings(namespace);
2314
- res.json(maskSettings(namespace, settings));
2315
- }
2316
- async function updateNamespaceSettings(req, res) {
2317
- const { namespace } = req.params;
2318
- const incoming = req.body;
2319
- if (typeof incoming !== "object" || Array.isArray(incoming)) {
2320
- res.status(400).json({ error: "Body must be a flat key-value object" });
2321
- return;
2322
- }
2323
- const sensitive = SENSITIVE_FIELDS2[namespace];
2324
- const toSave = {};
2325
- for (const [key, value] of Object.entries(incoming)) {
2326
- if (sensitive?.has(key) && (!value || value === MASKED))
2327
- continue;
2328
- toSave[key] = value;
2329
- }
2330
- await setSettings(namespace, toSave);
2331
- const updated = await getSettings(namespace);
2332
- res.json(maskSettings(namespace, updated));
2333
- }
2334
-
2335
- // ../core/dist/routes/admin.js
2336
- var router2 = Router2();
2337
- router2.use(authenticate);
2338
- router2.get("/content-types", authorize("content-types:read"), listContentTypes);
2339
- router2.post("/content-types", authorize("content-types:write"), createContentType);
2340
- router2.get("/content-types/:slug", authorize("content-types:read"), getContentType);
2341
- router2.put("/content-types/:slug", authorize("content-types:write"), updateContentType2);
2342
- router2.put("/content-types/:slug/default", authorize("content-types:write"), setDefaultContentType2);
2343
- router2.delete("/content-types/:slug", authorize("content-types:delete"), deleteContentType2);
2344
- router2.get("/content-types/:slug/entries", authorize("entries:read"), listEntries);
2345
- router2.get("/content-types/:slug/single", authorize("entries:read"), getSingleEntry);
2346
- router2.post("/content-types/:slug/entries", authorize("entries:write"), createEntry);
2347
- router2.get("/entries/:slug/:id", authorize("entries:read"), getEntry);
2348
- router2.put("/entries/:slug/:id", authorize("entries:write"), updateEntry);
2349
- router2.patch("/entries/:slug/:id/status", authorize("entries:write"), patchEntryStatus);
2350
- router2.delete("/entries/:slug/:id", authorize("entries:delete"), deleteEntry);
2351
- router2.get("/users/me", getMe);
2352
- router2.patch("/users/me", updateMe);
2353
- router2.patch("/users/me/password", changePassword);
2354
- router2.post("/users/me/avatar", upload.single("file"), uploadAvatar);
2355
- router2.post("/users/me/avatar/presign", presignAvatar);
2356
- router2.post("/users/me/avatar/confirm", confirmAvatar);
2357
- router2.delete("/users/me/avatar", deleteAvatar);
2358
- router2.get("/users/me/prefs/:key", getUserPref);
2359
- router2.put("/users/me/prefs/:key", setUserPref);
2360
- router2.get("/roles", authorize("settings:users:read"), listRoles);
2361
- router2.put("/roles/:id", authorize("settings:roles:write"), updateRole);
2362
- router2.post("/roles/reset", authorize("settings:roles:write"), resetRoles);
2363
- router2.get("/users", authorize("settings:users:read"), listUsers);
2364
- router2.post("/users", authorize("settings:users:write"), createUser);
2365
- router2.put("/users/:id", authorize("settings:users:write"), updateUser);
2366
- router2.delete("/users/:id", authorize("settings:users:delete"), deleteUser);
2367
- router2.get("/api-tokens", authorize("settings:api-tokens:read"), listApiTokens);
2368
- router2.post("/api-tokens", authorize("settings:api-tokens:write"), createApiToken);
2369
- router2.delete("/api-tokens/:id", authorize("settings:api-tokens:delete"), deleteApiToken);
2370
- router2.get("/folders", authorize("media:read"), listFolders);
2371
- router2.post("/folders", authorize("media:write"), createFolder);
2372
- router2.patch("/folders/:id", authorize("media:write"), renameFolder);
2373
- router2.delete("/folders/:id", authorize("media:delete"), deleteFolder);
2374
- router2.get("/media", authorize("media:read"), listMedia);
2375
- router2.post("/media/presign", authorize("media:write"), presignMedia);
2376
- router2.post("/media/confirm", authorize("media:write"), confirmMedia);
2377
- router2.post("/media", authorize("media:write"), upload.array("files", 500), uploadMedia);
2378
- router2.get("/media/:id/url", authorize("media:read"), getMediaUrl);
2379
- router2.patch("/media/:id", authorize("media:write"), updateMedia);
2380
- router2.delete("/media/:id", authorize("media:delete"), deleteMedia);
2381
- router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
2382
- router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
2383
- router2.get("/webhooks", authorize("settings:webhooks:read"), listWebhooks);
2384
- router2.post("/webhooks", authorize("settings:webhooks:write"), createWebhook);
2385
- router2.delete("/webhooks/:id", authorize("settings:webhooks:delete"), deleteWebhook);
2386
- var admin_default = router2;
2387
-
2388
- // ../core/dist/routes/public.js
2389
- import { Router as Router3 } from "express";
2390
-
2391
- // ../core/dist/middlewares/apiToken.js
2392
- import { createHash as createHash2 } from "crypto";
2393
- var READ_ONLY_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
2394
- async function apiToken(req, res, next) {
2395
- const header = req.headers.authorization;
2396
- if (!header?.startsWith("Bearer ")) {
2397
- res.status(401).json({ error: "API token required" });
2398
- return;
2399
- }
2400
- const raw = header.slice(7);
2401
- const hashed = createHash2("sha256").update(raw).digest("hex");
2402
- const { rows } = await pool_default.query("SELECT id, access_type FROM plank_api_tokens WHERE token = $1", [hashed]);
2403
- if (!rows[0]) {
2404
- res.status(401).json({ error: "Invalid API token" });
2405
- return;
2406
- }
2407
- if (rows[0].access_type === "read-only" && !READ_ONLY_METHODS.has(req.method)) {
2408
- res.status(403).json({ error: "This token only allows read access" });
2409
- return;
2410
- }
2411
- next();
2412
- }
2413
-
2414
- // ../core/dist/controllers/public.js
2415
- async function resolveMediaFields(entries, ct) {
2416
- const singleFields = ct.fields.filter((f) => f.type === "media").map((f) => f.name);
2417
- const galleryFields = ct.fields.filter((f) => f.type === "media-gallery").map((f) => f.name);
2418
- if (singleFields.length === 0 && galleryFields.length === 0)
2419
- return;
2420
- const idSet = /* @__PURE__ */ new Set();
2421
- for (const entry of entries) {
2422
- for (const name of singleFields) {
2423
- const val = entry[name];
2424
- if (typeof val === "string" && val && !val.startsWith("http"))
2425
- idSet.add(val);
2426
- }
2427
- for (const name of galleryFields) {
2428
- const val = entry[name];
2429
- if (Array.isArray(val)) {
2430
- for (const id of val) {
2431
- if (typeof id === "string" && id && !id.startsWith("http"))
2432
- idSet.add(id);
2433
- }
2434
- }
2435
- }
2436
- }
2437
- if (idSet.size === 0)
2438
- return;
2439
- const { rows } = await pool_default.query("SELECT id, provider_key FROM plank_media WHERE id = ANY($1)", [[...idSet]]);
2440
- const provider = await getProvider();
2441
- const urlMap = /* @__PURE__ */ new Map();
2442
- await Promise.all(rows.map(async (r) => {
2443
- urlMap.set(r.id, await provider.getUrl(r.provider_key));
2444
- }));
2445
- for (const entry of entries) {
2446
- for (const name of singleFields) {
2447
- const val = entry[name];
2448
- if (typeof val === "string" && urlMap.has(val))
2449
- entry[name] = urlMap.get(val);
2450
- }
2451
- for (const name of galleryFields) {
2452
- const val = entry[name];
2453
- if (Array.isArray(val)) {
2454
- entry[name] = val.map((id) => typeof id === "string" && urlMap.has(id) ? urlMap.get(id) : id);
2455
- }
2456
- }
2457
- }
2458
- }
2459
- var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
2460
- "status",
2461
- "published_data",
2462
- "published_at",
2463
- "scheduled_for",
2464
- "created_by",
2465
- "created_at",
2466
- "updated_at"
2467
- ]);
2468
- function stripSystemFields(row) {
2469
- return Object.fromEntries(Object.entries(row).filter(([k]) => !SYSTEM_FIELDS.has(k)));
2470
- }
2471
- async function resolveRelationFields(entries, ct) {
2472
- const scalarFields = ct.fields.filter((f) => f.type === "relation" && (f.relationType === "many-to-one" || f.relationType === "one-to-one" || !f.relationType) && f.relatedTable);
2473
- const mmFields = ct.fields.filter((f) => f.type === "relation" && (f.relationType ?? "many-to-one") === "many-to-many" && f.relatedTable);
2474
- const entryIds = entries.map((e) => e.id);
2475
- await Promise.all([
2476
- ...scalarFields.map(async (field) => {
2477
- const ids = entries.map((e) => e[field.name]).filter(Boolean);
2478
- if (ids.length === 0)
2479
- return;
2480
- assertSafeIdentifier(field.relatedTable);
2481
- const { rows } = await pool_default.query(`SELECT * FROM ${field.relatedTable} WHERE id = ANY($1)`, [
2482
- ids
2483
- ]);
2484
- const map = new Map(rows.map((r) => [r.id, stripSystemFields(r)]));
2485
- for (const entry of entries) {
2486
- const id = entry[field.name];
2487
- entry[field.name] = id ? map.get(id) ?? null : null;
2488
- }
2489
- }),
2490
- ...mmFields.map(async (field) => {
2491
- if (entryIds.length === 0)
2492
- return;
2493
- const jt = `_rel_${ct.tableName}_${field.name}`;
2494
- const { rows: jRows } = await pool_default.query(`SELECT source_id, target_id FROM ${jt} WHERE source_id = ANY($1)`, [entryIds]);
2495
- const allTargetIds = [...new Set(jRows.map((r) => r.target_id))];
2496
- const relatedMap = /* @__PURE__ */ new Map();
2497
- if (allTargetIds.length > 0) {
2498
- assertSafeIdentifier(field.relatedTable);
2499
- const { rows: relRows } = await pool_default.query(`SELECT * FROM ${field.relatedTable} WHERE id = ANY($1)`, [allTargetIds]);
2500
- for (const row of relRows)
2501
- relatedMap.set(row.id, stripSystemFields(row));
2502
- }
2503
- const sourceMap = /* @__PURE__ */ new Map();
2504
- for (const row of jRows) {
2505
- const obj = relatedMap.get(row.target_id);
2506
- if (!obj)
2507
- continue;
2508
- const list = sourceMap.get(row.source_id);
2509
- if (list)
2510
- list.push(obj);
2511
- else
2512
- sourceMap.set(row.source_id, [obj]);
2513
- }
2514
- for (const entry of entries) {
2515
- entry[field.name] = sourceMap.get(entry.id) ?? [];
2516
- }
2517
- })
2518
- ]);
2519
- }
2520
- async function resolveAuthorAvatars(entries) {
2521
- const provider = await getProvider();
2522
- await Promise.all(entries.map(async (entry) => {
2523
- const author = entry.author;
2524
- if (author?.avatar_url && !author.avatar_url.startsWith("http")) {
2525
- author.avatar_url = await provider.getUrl(author.avatar_url);
2526
- }
2527
- }));
2528
- }
2529
- function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
2530
- const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, ...rest } = row;
2531
- const source = statusParam === "published" && published_data ? published_data : rest;
2532
- const effective = { ...source };
2533
- if (locale) {
2534
- const localizedContainer = source && typeof source === "object" && source.localized && typeof source.localized === "object" ? source.localized : row.localized && typeof row.localized === "object" ? row.localized : {};
2535
- const localizableTypes = /* @__PURE__ */ new Set(["string", "text", "richtext", "uid"]);
2536
- for (const f of ct.fields) {
2537
- if (!localizableTypes.has(f.type))
2538
- continue;
2539
- let val = void 0;
2540
- if (localizedContainer[locale] && localizedContainer[locale][f.name] !== void 0) {
2541
- val = localizedContainer[locale][f.name];
2542
- } else {
2543
- for (const fb of fallbacks) {
2544
- if (localizedContainer[fb] && localizedContainer[fb][f.name] !== void 0) {
2545
- val = localizedContainer[fb][f.name];
2546
- break;
2547
- }
2548
- }
2549
- }
2550
- if (val !== void 0)
2551
- effective[f.name] = val;
2552
- }
2553
- }
2554
- const out = { id: row.id };
2555
- for (const field of ct.fields) {
2556
- if (field.name in effective)
2557
- out[field.name] = effective[field.name];
2558
- }
2559
- out.status = row.status;
2560
- out.published_at = row.published_at ?? null;
2561
- out.created_at = row.created_at;
2562
- out.updated_at = row.updated_at;
2563
- out.author = _author_first_name || _author_last_name ? {
2564
- first_name: _author_first_name ?? null,
2565
- last_name: _author_last_name ?? null,
2566
- avatar_url: _author_avatar_url ?? null,
2567
- job_title: _author_job_title ?? null,
2568
- organization: _author_organization ?? null,
2569
- country: _author_country ?? null
2570
- } : null;
2571
- return out;
2572
- }
2573
- var listPublicEntries = async (req, res) => {
2574
- const ct = await findContentTypeBySlug(req.params.slug);
2575
- if (!ct) {
2576
- res.status(404).json({ error: "Not found" });
2577
- return;
2578
- }
2579
- assertSafeIdentifier(ct.tableName);
2580
- if (ct.kind === "single") {
2581
- const statusParam2 = String(req.query.status ?? "published");
2582
- const locale2 = req.query.locale ? String(req.query.locale) : void 0;
2583
- const fallbacks2 = req.query.fallback ? String(req.query.fallback).split(",") : [];
2584
- const statusClause = statusParam2 === "published" || statusParam2 === "draft" ? `WHERE e.status = $1` : "";
2585
- const values = statusClause ? [statusParam2] : [];
2586
- 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
2587
- FROM ${ct.tableName} e
2588
- LEFT JOIN plank_users u ON u.id = e.created_by
2589
- ${statusClause} LIMIT 1`, values);
2590
- if (!rows2[0]) {
2591
- res.status(404).json({ error: "Not found" });
2592
- return;
2593
- }
2594
- const entry = serializeEntry(rows2[0], ct, statusParam2, locale2, fallbacks2);
2595
- await Promise.all([
2596
- resolveMediaFields([entry], ct),
2597
- resolveAuthorAvatars([entry]),
2598
- resolveRelationFields([entry], ct)
2599
- ]);
2600
- res.json(entry);
2601
- return;
2602
- }
2603
- const page = Math.max(1, parseInt(String(req.query.page ?? 1)));
2604
- const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? 20))));
2605
- const offset = (page - 1) * limit;
2606
- const locale = req.query.locale ? String(req.query.locale) : void 0;
2607
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
2608
- const knownFields = new Set(ct.fields.map((f) => f.name));
2609
- const systemSortFields = /* @__PURE__ */ new Set(["created_at", "updated_at", "published_at"]);
2610
- const filterClauses = [];
2611
- const filterValues = [];
2612
- const statusParam = String(req.query.status ?? "published");
2613
- if (statusParam === "published" || statusParam === "draft") {
2614
- filterClauses.push(`e.status = $${filterValues.length + 1}`);
2615
- filterValues.push(statusParam);
2616
- }
2617
- const rawSort = String(req.query.sort ?? "created_at");
2618
- const sortField = knownFields.has(rawSort) || systemSortFields.has(rawSort) ? rawSort : "created_at";
2619
- assertSafeIdentifier(sortField);
2620
- const sortDir = String(req.query.order ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
2621
- for (const [key, value] of Object.entries(req.query)) {
2622
- if (key === "page" || key === "limit" || key === "status" || key === "sort" || key === "order")
2623
- continue;
2624
- if (knownFields.has(key)) {
2625
- assertSafeIdentifier(key);
2626
- filterClauses.push(`e.${key} = $${filterValues.length + 1}`);
2627
- filterValues.push(value);
2628
- }
2629
- }
2630
- const where = filterClauses.length > 0 ? `WHERE ${filterClauses.join(" AND ")}` : "";
2631
- const limitParam = filterValues.length + 1;
2632
- const offsetParam = filterValues.length + 2;
2633
- const [{ rows }, { rows: countRows }] = await Promise.all([
2634
- 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
2635
- FROM ${ct.tableName} e
2636
- LEFT JOIN plank_users u ON u.id = e.created_by
2637
- ${where} ORDER BY e.${sortField} ${sortDir} LIMIT $${limitParam} OFFSET $${offsetParam}`, [...filterValues, limit, offset]),
2638
- pool_default.query(`SELECT COUNT(*) as count FROM ${ct.tableName} e ${where}`, filterValues)
2639
- ]);
2640
- const data = rows.map((row) => serializeEntry(row, ct, statusParam, locale, fallbacks));
2641
- await Promise.all([
2642
- resolveMediaFields(data, ct),
2643
- resolveAuthorAvatars(data),
2644
- resolveRelationFields(data, ct)
2645
- ]);
2646
- res.json({ data, total: parseInt(countRows[0].count), page, limit });
2647
- };
2648
- var getPublicEntry = async (req, res) => {
2649
- const ct = await findContentTypeBySlug(req.params.slug);
2650
- if (!ct) {
2651
- res.status(404).json({ error: "Not found" });
2652
- return;
2653
- }
2654
- assertSafeIdentifier(ct.tableName);
2655
- const statusParam = String(req.query.status ?? "published");
2656
- const statusClause = statusParam === "published" || statusParam === "draft" ? ` AND e.status = $2` : "";
2657
- const values = statusClause ? [req.params.id, statusParam] : [req.params.id];
2658
- 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
2659
- FROM ${ct.tableName} e
2660
- LEFT JOIN plank_users u ON u.id = e.created_by
2661
- WHERE e.id = $1${statusClause}`, values);
2662
- if (!rows[0]) {
2663
- res.status(404).json({ error: "Not found" });
2664
- return;
2665
- }
2666
- const locale = req.query.locale ? String(req.query.locale) : void 0;
2667
- const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
2668
- const entry = serializeEntry(rows[0], ct, statusParam, locale, fallbacks);
2669
- await Promise.all([
2670
- resolveMediaFields([entry], ct),
2671
- resolveAuthorAvatars([entry]),
2672
- resolveRelationFields([entry], ct)
2673
- ]);
2674
- res.json(entry);
2675
- };
2676
-
2677
- // ../core/dist/routes/public.js
2678
- var router3 = Router3();
2679
- router3.use(apiToken);
2680
- router3.get("/:slug", listPublicEntries);
2681
- router3.get("/:slug/:id", getPublicEntry);
2682
- var public_default = router3;
2683
-
2684
- // ../core/dist/middlewares/errorHandler.js
2685
- import { ZodError, flattenError as flattenError6 } from "zod";
2686
- function errorHandler(err, _req, res, _next) {
2687
- if (err instanceof ValidationError) {
2688
- res.status(400).json({ errors: err.errors });
2689
- return;
2690
- }
2691
- if (err instanceof SchemaError) {
2692
- res.status(400).json({ error: err.message });
2693
- return;
2694
- }
2695
- if (err instanceof ZodError) {
2696
- res.status(400).json({ errors: flattenError6(err, (i) => i.message) });
2697
- return;
2698
- }
2699
- console.error("[plank] Unhandled error:", err);
2700
- res.status(500).json({ error: "Internal server error" });
2701
- }
2702
-
2703
- // ../core/dist/app.js
2704
- var app = express();
2705
- app.use(helmet({
2706
- contentSecurityPolicy: {
2707
- directives: {
2708
- ...helmet.contentSecurityPolicy.getDefaultDirectives(),
2709
- "img-src": ["'self'", "data:", "https:"],
2710
- "connect-src": ["'self'", "https:"]
2711
- }
2712
- }
2713
- }));
2714
- app.use(express.json());
2715
- var PORT = process.env.PLANK_PORT ?? "5500";
2716
- var adminOrigin = process.env.PLANK_PUBLIC_URL ?? `http://localhost:${PORT}`;
2717
- var cmsCorOptions = cors({ origin: adminOrigin, credentials: true });
2718
- app.use("/cms/auth", cmsCorOptions, auth_default);
2719
- app.use("/cms/admin", cmsCorOptions, admin_default);
2720
- app.use("/api", cors(), public_default);
2721
- app.get("/", (_req, res) => res.redirect("/admin"));
2722
- var adminDist = process.env.PLANK_ADMIN_DIST ?? join3(dirname2(fileURLToPath2(import.meta.url)), "../public/admin");
2723
- app.use("/admin", express.static(adminDist));
2724
- app.get("/admin/*path", (_req, res) => res.sendFile(join3(adminDist, "index.html")));
2725
- app.use(errorHandler);
2726
- var app_default = app;
2727
-
2728
- // ../core/dist/server.js
2729
- async function start() {
2730
- const PORT2 = process.env.PLANK_PORT ?? 5500;
2731
- await migrate();
2732
- await syncAllTables();
2733
- app_default.listen(PORT2, () => {
2734
- const base = process.env.PLANK_PUBLIC_URL ?? `http://localhost:${PORT2}`;
2735
- console.log(" \u25B2 Plank CMS by AM25");
2736
- console.log(` Admin \u2192 ${base}/admin`);
2737
- console.log(` API \u2192 ${base}/api`);
2738
- });
2739
- }
2740
- export {
2741
- start
2742
- };