next-tinacms-cloudinary 26.0.2 → 26.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/handlers.js CHANGED
@@ -1,8 +1,86 @@
1
1
  // src/handlers.ts
2
2
  import { v2 as cloudinary } from "cloudinary";
3
- import path from "path";
3
+ import path2 from "path";
4
4
  import multer from "multer";
5
5
  import { promisify } from "util";
6
+
7
+ // src/media-key.ts
8
+ import path from "node:path";
9
+ var MediaKeyError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "MediaKeyError";
13
+ }
14
+ };
15
+ function stripSlashes(value) {
16
+ let start = 0;
17
+ let end = value.length;
18
+ while (start < end && value[start] === "/") start++;
19
+ while (end > start && value[end - 1] === "/") end--;
20
+ return value.slice(start, end);
21
+ }
22
+ function normalizeMediaRoot(mediaRoot) {
23
+ if (!mediaRoot) {
24
+ return "";
25
+ }
26
+ return stripSlashes(mediaRoot);
27
+ }
28
+ function resolveKey(mediaRoot, rawKey, options) {
29
+ if (typeof rawKey !== "string" || rawKey.trim() === "") {
30
+ throw new MediaKeyError("a media key is required");
31
+ }
32
+ let decoded;
33
+ if (options?.decode === false) {
34
+ decoded = rawKey;
35
+ } else {
36
+ try {
37
+ decoded = decodeURIComponent(rawKey);
38
+ } catch {
39
+ throw new MediaKeyError("media key is not valid");
40
+ }
41
+ }
42
+ if (decoded.includes("\0") || decoded.includes("\\")) {
43
+ throw new MediaKeyError("media key is not valid");
44
+ }
45
+ if (decoded.startsWith("/") || /^[a-zA-Z]:/.test(decoded)) {
46
+ throw new MediaKeyError("absolute media keys are not allowed");
47
+ }
48
+ const normalized = path.posix.normalize(decoded).replace(/^\.\//, "");
49
+ if (normalized === ".." || normalized.startsWith("../")) {
50
+ throw new MediaKeyError("media key may not traverse directories");
51
+ }
52
+ if (normalized === "" || normalized === ".") {
53
+ throw new MediaKeyError("a media key is required");
54
+ }
55
+ const root = normalizeMediaRoot(mediaRoot);
56
+ if (!root) {
57
+ return normalized;
58
+ }
59
+ const scoped = normalized === root || normalized.startsWith(root + "/") ? normalized : path.posix.join(root, normalized);
60
+ if (scoped !== root && !scoped.startsWith(root + "/")) {
61
+ throw new MediaKeyError("media key escapes mediaRoot");
62
+ }
63
+ return scoped;
64
+ }
65
+ function resolveDirectory(rawDirectory) {
66
+ if (typeof rawDirectory !== "string" || rawDirectory.trim() === "") {
67
+ return "";
68
+ }
69
+ if (rawDirectory.includes("\0") || rawDirectory.includes("\\")) {
70
+ throw new MediaKeyError("media directory is not valid");
71
+ }
72
+ const trimmed = stripSlashes(rawDirectory);
73
+ const normalized = path.posix.normalize(trimmed).replace(/^\.\//, "");
74
+ if (normalized === ".." || normalized.startsWith("../")) {
75
+ throw new MediaKeyError("media directory may not traverse directories");
76
+ }
77
+ if (normalized === "" || normalized === ".") {
78
+ return "";
79
+ }
80
+ return normalized + "/";
81
+ }
82
+
83
+ // src/handlers.ts
6
84
  var mediaHandlerConfig = {
7
85
  api: {
8
86
  bodyParser: false
@@ -43,10 +121,25 @@ async function uploadMedia(req, res) {
43
121
  }).single("file")
44
122
  );
45
123
  await upload(req, res);
124
+ if (!req.file) {
125
+ return res.status(400).json({ message: "file is required" });
126
+ }
46
127
  const { directory } = req.body;
128
+ const filename = req.file.originalname;
129
+ let folder;
130
+ try {
131
+ const rawFolder = (directory || "").replace(/^\/+/, "");
132
+ folder = rawFolder ? resolveKey("", rawFolder, { decode: false }) : "";
133
+ resolveKey("", filename, { decode: false });
134
+ } catch (e) {
135
+ if (e instanceof MediaKeyError) {
136
+ return res.status(400).json({ message: e.message });
137
+ }
138
+ throw e;
139
+ }
47
140
  try {
48
141
  const result = await cloudinary.uploader.upload(req.file.path, {
49
- folder: directory.replace(/^\//, ""),
142
+ folder,
50
143
  use_filename: true,
51
144
  overwrite: false,
52
145
  resource_type: "auto"
@@ -65,6 +158,17 @@ async function listMedia(req, res, opts) {
65
158
  filesOnly: req.query.filesOnly === "true" || false
66
159
  };
67
160
  const useRootDirectory = !mediaListOptions.directory || mediaListOptions.directory === "/" || mediaListOptions.directory === '""';
161
+ if (!useRootDirectory) {
162
+ try {
163
+ resolveDirectory(mediaListOptions.directory);
164
+ } catch (e) {
165
+ if (e instanceof MediaKeyError) {
166
+ res.status(400).json({ message: e.message });
167
+ return;
168
+ }
169
+ throw e;
170
+ }
171
+ }
68
172
  const query = useRootDirectory ? 'folder=""' : `folder="${mediaListOptions.directory}"`;
69
173
  const response = await cloudinary.search.expression(query).max_results(mediaListOptions.limit).next_cursor(mediaListOptions.offset).execute();
70
174
  const files = response.resources.map(getCloudinaryToTinaFunc(opts));
@@ -100,8 +204,8 @@ async function listMedia(req, res, opts) {
100
204
  return {
101
205
  id: folder.path,
102
206
  type: "dir",
103
- filename: path.basename(folder.path),
104
- directory: path.dirname(folder.path)
207
+ filename: path2.basename(folder.path),
208
+ directory: path2.dirname(folder.path)
105
209
  };
106
210
  });
107
211
  }
@@ -124,7 +228,16 @@ var findErrorMessage = (e) => {
124
228
  };
125
229
  async function deleteAsset(req, res) {
126
230
  const { media } = req.query;
127
- const [, public_id] = media;
231
+ const [, rawPublicId] = media;
232
+ let public_id;
233
+ try {
234
+ public_id = resolveKey("", rawPublicId, { decode: false });
235
+ } catch (e) {
236
+ if (e instanceof MediaKeyError) {
237
+ return res.status(400).json({ message: e.message });
238
+ }
239
+ throw e;
240
+ }
128
241
  cloudinary.uploader.destroy(public_id, {}, (err) => {
129
242
  if (err) res.status(500);
130
243
  res.json({
@@ -140,8 +253,8 @@ function getCloudinaryToTinaFunc(opts) {
140
253
  useHttps = opts.useHttps;
141
254
  }
142
255
  const sel = useHttps ? "secure_url" : "url";
143
- const filename = path.basename(file.public_id);
144
- const directory = path.dirname(file.public_id);
256
+ const filename = path2.basename(file.public_id);
257
+ const directory = path2.dirname(file.public_id);
145
258
  return {
146
259
  id: file.public_id,
147
260
  filename,
@@ -0,0 +1,26 @@
1
+ export declare class MediaKeyError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Resolve a caller-supplied media key against the configured mediaRoot.
6
+ *
7
+ * @throws {MediaKeyError} when the key is empty, absolute, contains a NUL
8
+ * byte, uses path traversal, or escapes mediaRoot.
9
+ */
10
+ export declare function resolveKey(mediaRoot: string, rawKey: unknown, options?: {
11
+ decode?: boolean;
12
+ }): string;
13
+ /**
14
+ * Resolve a caller-supplied listing directory into a relative prefix.
15
+ *
16
+ * The directory is optional: an empty / root directory returns `''` (list the
17
+ * mediaRoot itself). Otherwise leading/trailing slashes are stripped (matching
18
+ * the historical handler behaviour) and the result is normalised so it cannot
19
+ * traverse upward — `path.join(mediaRoot, directory)` would otherwise collapse
20
+ * `..` and list objects outside mediaRoot. Returns a prefix with a trailing
21
+ * slash so it can be joined onto mediaRoot directly.
22
+ *
23
+ * @throws {MediaKeyError} when the directory contains a NUL byte or backslash,
24
+ * or uses upward path traversal.
25
+ */
26
+ export declare function resolveDirectory(rawDirectory: unknown): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "next-tinacms-cloudinary",
3
3
  "type": "module",
4
- "version": "26.0.2",
4
+ "version": "26.0.4",
5
5
  "main": "dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "files": [
@@ -30,11 +30,11 @@
30
30
  "react": "^18.3.1",
31
31
  "react-dom": "^18.3.1",
32
32
  "typescript": "^5.7.3",
33
- "@tinacms/scripts": "1.6.1",
34
- "tinacms": "3.9.2"
33
+ "@tinacms/scripts": "1.6.2",
34
+ "tinacms": "3.9.4"
35
35
  },
36
36
  "peerDependencies": {
37
- "tinacms": "3.9.2"
37
+ "tinacms": "3.9.4"
38
38
  },
39
39
  "publishConfig": {
40
40
  "registry": "https://registry.npmjs.org"