@structcms/api 0.1.0 → 0.1.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,9 +1,20 @@
1
1
  // src/media/resolve.ts
2
2
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
- var MEDIA_FIELD_SUFFIXES = ["_image", "_media", "_photo", "_thumbnail", "_avatar", "_icon"];
3
+ var MEDIA_FIELD_SUFFIXES = [
4
+ "_image",
5
+ "_media",
6
+ "_photo",
7
+ "_thumbnail",
8
+ "_avatar",
9
+ "_icon",
10
+ "_file",
11
+ "_document",
12
+ "_attachment",
13
+ "_download"
14
+ ];
4
15
  function isMediaField(fieldName) {
5
16
  const lower = fieldName.toLowerCase();
6
- if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon") {
17
+ if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon" || lower === "file" || lower === "document" || lower === "attachment" || lower === "download") {
7
18
  return true;
8
19
  }
9
20
  return MEDIA_FIELD_SUFFIXES.some((suffix) => lower.endsWith(suffix));
@@ -74,6 +85,7 @@ async function handleGetPageBySlug(adapter, mediaAdapter, slug) {
74
85
  }
75
86
 
76
87
  // src/media/types.ts
88
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
77
89
  var ALLOWED_MIME_TYPES = [
78
90
  "image/jpeg",
79
91
  "image/png",
@@ -81,6 +93,23 @@ var ALLOWED_MIME_TYPES = [
81
93
  "image/webp",
82
94
  "image/svg+xml"
83
95
  ];
96
+ var ALLOWED_DOCUMENT_MIME_TYPES = [
97
+ "application/pdf",
98
+ "application/msword",
99
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
100
+ "application/vnd.ms-excel",
101
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
102
+ "application/vnd.ms-powerpoint",
103
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
104
+ "text/plain",
105
+ "text/csv",
106
+ "application/zip",
107
+ "application/gzip"
108
+ ];
109
+ var ALL_ALLOWED_MIME_TYPES = [
110
+ ...ALLOWED_MIME_TYPES,
111
+ ...ALLOWED_DOCUMENT_MIME_TYPES
112
+ ];
84
113
 
85
114
  // src/media/handlers.ts
86
115
  var MediaValidationError = class extends Error {
@@ -91,16 +120,25 @@ var MediaValidationError = class extends Error {
91
120
  }
92
121
  };
93
122
  function validateMimeType(mimeType) {
94
- const allowed = ALLOWED_MIME_TYPES;
123
+ const allowed = ALL_ALLOWED_MIME_TYPES;
95
124
  if (!allowed.includes(mimeType)) {
96
125
  throw new MediaValidationError(
97
- `Invalid file type: ${mimeType}. Allowed types: ${ALLOWED_MIME_TYPES.join(", ")}`,
126
+ `Invalid file type: ${mimeType}. Allowed types: ${ALL_ALLOWED_MIME_TYPES.join(", ")}`,
98
127
  "INVALID_MIME_TYPE"
99
128
  );
100
129
  }
101
130
  }
131
+ function validateFileSize(size) {
132
+ if (size > MAX_FILE_SIZE) {
133
+ throw new MediaValidationError(
134
+ `File size exceeds maximum allowed size of ${MAX_FILE_SIZE / 1024 / 1024}MB`,
135
+ "FILE_TOO_LARGE"
136
+ );
137
+ }
138
+ }
102
139
  async function handleUploadMedia(adapter, input) {
103
140
  validateMimeType(input.mimeType);
141
+ validateFileSize(input.size);
104
142
  return adapter.upload(input);
105
143
  }
106
144
  async function handleGetMedia(adapter, id) {
@@ -147,6 +185,12 @@ var SANITIZE_OPTIONS = {
147
185
  function sanitizeString(value) {
148
186
  return sanitizeHtml(value, SANITIZE_OPTIONS);
149
187
  }
188
+ function stripTags(value) {
189
+ return sanitizeHtml(value, {
190
+ allowedTags: [],
191
+ allowedAttributes: {}
192
+ });
193
+ }
150
194
  function sanitizeValue(value) {
151
195
  if (typeof value === "string") {
152
196
  return sanitizeString(value);
@@ -213,10 +257,11 @@ var StorageValidationError = class extends Error {
213
257
  }
214
258
  };
215
259
  async function handleCreatePage(adapter, input) {
216
- if (!input.title.trim()) {
260
+ const sanitizedTitle = stripTags(input.title).trim();
261
+ if (!sanitizedTitle) {
217
262
  throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
218
263
  }
219
- const slug = input.slug?.trim() || generateSlug(input.title);
264
+ const slug = input.slug?.trim() || generateSlug(sanitizedTitle);
220
265
  if (!slug) {
221
266
  throw new StorageValidationError(
222
267
  "Could not generate a valid slug from the provided title",
@@ -229,6 +274,7 @@ async function handleCreatePage(adapter, input) {
229
274
  const sanitizedSections = input.sections ? sanitizeSectionData(input.sections) : void 0;
230
275
  return adapter.createPage({
231
276
  ...input,
277
+ title: sanitizedTitle,
232
278
  slug: uniqueSlug,
233
279
  sections: sanitizedSections
234
280
  });
@@ -237,8 +283,12 @@ async function handleUpdatePage(adapter, input) {
237
283
  if (!input.id.trim()) {
238
284
  throw new StorageValidationError("Page ID must not be empty", "EMPTY_ID");
239
285
  }
240
- if (input.title !== void 0 && !input.title.trim()) {
241
- throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
286
+ let sanitizedTitle;
287
+ if (input.title !== void 0) {
288
+ sanitizedTitle = stripTags(input.title).trim();
289
+ if (!sanitizedTitle) {
290
+ throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
291
+ }
242
292
  }
243
293
  if (input.slug !== void 0) {
244
294
  const slug = input.slug.trim();
@@ -251,7 +301,11 @@ async function handleUpdatePage(adapter, input) {
251
301
  throw new StorageValidationError(`Slug "${slug}" is already in use`, "DUPLICATE_SLUG");
252
302
  }
253
303
  }
254
- const sanitizedInput = input.sections ? { ...input, sections: sanitizeSectionData(input.sections) } : input;
304
+ const sanitizedInput = {
305
+ ...input,
306
+ ...sanitizedTitle !== void 0 && { title: sanitizedTitle },
307
+ ...input.sections && { sections: sanitizeSectionData(input.sections) }
308
+ };
255
309
  return adapter.updatePage(sanitizedInput);
256
310
  }
257
311
  async function handleDeletePage(adapter, id) {
@@ -261,38 +315,46 @@ async function handleDeletePage(adapter, id) {
261
315
  return adapter.deletePage(id);
262
316
  }
263
317
  async function handleCreateNavigation(adapter, input) {
264
- if (!input.name.trim()) {
318
+ const sanitizedName = stripTags(input.name).trim();
319
+ if (!sanitizedName) {
265
320
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
266
321
  }
267
322
  const existingNavigations = await adapter.listNavigations();
268
323
  const existingNames = existingNavigations.map((n) => n.name);
269
- if (existingNames.includes(input.name.trim())) {
324
+ if (existingNames.includes(sanitizedName)) {
270
325
  throw new StorageValidationError(
271
- `Navigation name "${input.name.trim()}" is already in use`,
326
+ `Navigation name "${sanitizedName}" is already in use`,
272
327
  "DUPLICATE_NAME"
273
328
  );
274
329
  }
275
- return adapter.createNavigation(input);
330
+ return adapter.createNavigation({
331
+ ...input,
332
+ name: sanitizedName
333
+ });
276
334
  }
277
335
  async function handleUpdateNavigation(adapter, input) {
278
336
  if (!input.id.trim()) {
279
337
  throw new StorageValidationError("Navigation ID must not be empty", "EMPTY_ID");
280
338
  }
339
+ let sanitizedName;
281
340
  if (input.name !== void 0) {
282
- const name = input.name.trim();
283
- if (!name) {
341
+ sanitizedName = stripTags(input.name).trim();
342
+ if (!sanitizedName) {
284
343
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
285
344
  }
286
345
  const existingNavigations = await adapter.listNavigations();
287
346
  const existingNames = existingNavigations.filter((n) => n.id !== input.id).map((n) => n.name);
288
- if (existingNames.includes(name)) {
347
+ if (existingNames.includes(sanitizedName)) {
289
348
  throw new StorageValidationError(
290
- `Navigation name "${name}" is already in use`,
349
+ `Navigation name "${sanitizedName}" is already in use`,
291
350
  "DUPLICATE_NAME"
292
351
  );
293
352
  }
294
353
  }
295
- return adapter.updateNavigation(input);
354
+ return adapter.updateNavigation({
355
+ ...input,
356
+ ...sanitizedName !== void 0 && { name: sanitizedName }
357
+ });
296
358
  }
297
359
  async function handleDeleteNavigation(adapter, id) {
298
360
  if (!id.trim()) {
@@ -328,7 +390,7 @@ function errorResponse(error, fallbackStatus = 500) {
328
390
  if (error instanceof StorageValidationError || error instanceof MediaValidationError || error instanceof SyntaxError) {
329
391
  return jsonResponse({ error: getErrorMessage(error) }, 400);
330
392
  }
331
- return jsonResponse({ error: getErrorMessage(error) }, fallbackStatus);
393
+ return jsonResponse({ error: "Internal Server Error" }, fallbackStatus);
332
394
  }
333
395
  async function resolveParams(context) {
334
396
  return context.params;
@@ -652,11 +714,51 @@ function createNextPageByIdRoute(config) {
652
714
  }
653
715
  };
654
716
  }
717
+ function getQueryParam(url, name) {
718
+ const queryStart = url.indexOf("?");
719
+ if (queryStart === -1) return null;
720
+ for (const pair of url.slice(queryStart + 1).split("&")) {
721
+ const eqIndex = pair.indexOf("=");
722
+ const key = decodeURIComponent(eqIndex === -1 ? pair : pair.slice(0, eqIndex));
723
+ if (key === name) return eqIndex === -1 ? "" : decodeURIComponent(pair.slice(eqIndex + 1));
724
+ }
725
+ return null;
726
+ }
727
+ function parseMediaFilter(request) {
728
+ if (!request.url) {
729
+ return void 0;
730
+ }
731
+ const category = getQueryParam(request.url, "category");
732
+ const limit = getQueryParam(request.url, "limit");
733
+ const offset = getQueryParam(request.url, "offset");
734
+ const filter = {};
735
+ let hasFilter = false;
736
+ if (category === "image" || category === "document") {
737
+ filter.category = category;
738
+ hasFilter = true;
739
+ }
740
+ if (limit) {
741
+ const parsed = Number.parseInt(limit, 10);
742
+ if (!Number.isNaN(parsed) && parsed > 0) {
743
+ filter.limit = parsed;
744
+ hasFilter = true;
745
+ }
746
+ }
747
+ if (offset) {
748
+ const parsed = Number.parseInt(offset, 10);
749
+ if (!Number.isNaN(parsed) && parsed >= 0) {
750
+ filter.offset = parsed;
751
+ hasFilter = true;
752
+ }
753
+ }
754
+ return hasFilter ? filter : void 0;
755
+ }
655
756
  function createNextMediaRoute(config) {
656
757
  return {
657
- GET: async (_request) => {
758
+ GET: async (request) => {
658
759
  try {
659
- const media = await handleListMedia(config.mediaAdapter);
760
+ const filter = parseMediaFilter(request);
761
+ const media = await handleListMedia(config.mediaAdapter, filter);
660
762
  return jsonResponse(media);
661
763
  } catch (error) {
662
764
  return errorResponse(error);
@@ -917,8 +1019,10 @@ function createNextAuthOAuthRoute(config, Response) {
917
1019
  { status: 400 }
918
1020
  );
919
1021
  }
920
- const message = err instanceof Error ? err.message : "OAuth initialization failed";
921
- return Response.json({ error: { message, code: "OAUTH_ERROR" } }, { status: 500 });
1022
+ return Response.json(
1023
+ { error: { message: "Internal Server Error", code: "OAUTH_ERROR" } },
1024
+ { status: 500 }
1025
+ );
922
1026
  }
923
1027
  };
924
1028
  }
@@ -936,8 +1040,10 @@ function createNextAuthSignInRoute(config, Response) {
936
1040
  { status: 400 }
937
1041
  );
938
1042
  }
939
- const message = err instanceof Error ? err.message : "Sign in failed";
940
- return Response.json({ error: { message, code: "AUTH_ERROR" } }, { status: 401 });
1043
+ return Response.json(
1044
+ { error: { message: "Invalid credentials", code: "AUTH_ERROR" } },
1045
+ { status: 401 }
1046
+ );
941
1047
  }
942
1048
  };
943
1049
  }
@@ -954,8 +1060,10 @@ function createNextAuthSignOutRoute(config, Response) {
954
1060
  await handleSignOut(config.authAdapter, token);
955
1061
  return Response.json({ message: "Signed out successfully" }, { status: 200 });
956
1062
  } catch (error) {
957
- const message = error instanceof Error ? error.message : "Sign out failed";
958
- return Response.json({ error: { message, code: "SIGNOUT_ERROR" } }, { status: 500 });
1063
+ return Response.json(
1064
+ { error: { message: "Internal Server Error", code: "SIGNOUT_ERROR" } },
1065
+ { status: 500 }
1066
+ );
959
1067
  }
960
1068
  };
961
1069
  }
@@ -978,8 +1086,10 @@ function createNextAuthVerifyRoute(config, Response) {
978
1086
  }
979
1087
  return Response.json(user, { status: 200 });
980
1088
  } catch (error) {
981
- const message = error instanceof Error ? error.message : "Verification failed";
982
- return Response.json({ error: { message, code: "VERIFY_ERROR" } }, { status: 401 });
1089
+ return Response.json(
1090
+ { error: { message: "Authentication failed", code: "VERIFY_ERROR" } },
1091
+ { status: 401 }
1092
+ );
983
1093
  }
984
1094
  };
985
1095
  }
@@ -997,8 +1107,10 @@ function createNextAuthRefreshRoute(config, Response) {
997
1107
  const session = await handleRefreshSession(config.authAdapter, refreshToken);
998
1108
  return Response.json(session, { status: 200 });
999
1109
  } catch (error) {
1000
- const message = error instanceof Error ? error.message : "Refresh failed";
1001
- return Response.json({ error: { message, code: "REFRESH_ERROR" } }, { status: 401 });
1110
+ return Response.json(
1111
+ { error: { message: "Session refresh failed", code: "REFRESH_ERROR" } },
1112
+ { status: 401 }
1113
+ );
1002
1114
  }
1003
1115
  };
1004
1116
  }
@@ -1021,8 +1133,10 @@ function createNextAuthCurrentUserRoute(config, Response) {
1021
1133
  }
1022
1134
  return Response.json(user, { status: 200 });
1023
1135
  } catch (error) {
1024
- const message = error instanceof Error ? error.message : "Failed to get user";
1025
- return Response.json({ error: { message, code: "GET_USER_ERROR" } }, { status: 500 });
1136
+ return Response.json(
1137
+ { error: { message: "Internal Server Error", code: "GET_USER_ERROR" } },
1138
+ { status: 500 }
1139
+ );
1026
1140
  }
1027
1141
  };
1028
1142
  }