next-tinacms-s3 23.0.2 → 23.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 +116 -14
- package/dist/media-key.d.ts +26 -0
- package/package.json +4 -4
package/dist/handlers.js
CHANGED
|
@@ -7,7 +7,85 @@ import {
|
|
|
7
7
|
HeadObjectCommand
|
|
8
8
|
} from "@aws-sdk/client-s3";
|
|
9
9
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
10
|
+
import path2 from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/media-key.ts
|
|
10
13
|
import path from "node:path";
|
|
14
|
+
var MediaKeyError = class extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "MediaKeyError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function stripSlashes(value) {
|
|
21
|
+
let start = 0;
|
|
22
|
+
let end = value.length;
|
|
23
|
+
while (start < end && value[start] === "/") start++;
|
|
24
|
+
while (end > start && value[end - 1] === "/") end--;
|
|
25
|
+
return value.slice(start, end);
|
|
26
|
+
}
|
|
27
|
+
function normalizeMediaRoot(mediaRoot) {
|
|
28
|
+
if (!mediaRoot) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
return stripSlashes(mediaRoot);
|
|
32
|
+
}
|
|
33
|
+
function resolveKey(mediaRoot, rawKey, options) {
|
|
34
|
+
if (typeof rawKey !== "string" || rawKey.trim() === "") {
|
|
35
|
+
throw new MediaKeyError("a media key is required");
|
|
36
|
+
}
|
|
37
|
+
let decoded;
|
|
38
|
+
if (options?.decode === false) {
|
|
39
|
+
decoded = rawKey;
|
|
40
|
+
} else {
|
|
41
|
+
try {
|
|
42
|
+
decoded = decodeURIComponent(rawKey);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new MediaKeyError("media key is not valid");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (decoded.includes("\0") || decoded.includes("\\")) {
|
|
48
|
+
throw new MediaKeyError("media key is not valid");
|
|
49
|
+
}
|
|
50
|
+
if (decoded.startsWith("/") || /^[a-zA-Z]:/.test(decoded)) {
|
|
51
|
+
throw new MediaKeyError("absolute media keys are not allowed");
|
|
52
|
+
}
|
|
53
|
+
const normalized = path.posix.normalize(decoded).replace(/^\.\//, "");
|
|
54
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
55
|
+
throw new MediaKeyError("media key may not traverse directories");
|
|
56
|
+
}
|
|
57
|
+
if (normalized === "" || normalized === ".") {
|
|
58
|
+
throw new MediaKeyError("a media key is required");
|
|
59
|
+
}
|
|
60
|
+
const root = normalizeMediaRoot(mediaRoot);
|
|
61
|
+
if (!root) {
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
const scoped = normalized === root || normalized.startsWith(root + "/") ? normalized : path.posix.join(root, normalized);
|
|
65
|
+
if (scoped !== root && !scoped.startsWith(root + "/")) {
|
|
66
|
+
throw new MediaKeyError("media key escapes mediaRoot");
|
|
67
|
+
}
|
|
68
|
+
return scoped;
|
|
69
|
+
}
|
|
70
|
+
function resolveDirectory(rawDirectory) {
|
|
71
|
+
if (typeof rawDirectory !== "string" || rawDirectory.trim() === "") {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
if (rawDirectory.includes("\0") || rawDirectory.includes("\\")) {
|
|
75
|
+
throw new MediaKeyError("media directory is not valid");
|
|
76
|
+
}
|
|
77
|
+
const trimmed = stripSlashes(rawDirectory);
|
|
78
|
+
const normalized = path.posix.normalize(trimmed).replace(/^\.\//, "");
|
|
79
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
80
|
+
throw new MediaKeyError("media directory may not traverse directories");
|
|
81
|
+
}
|
|
82
|
+
if (normalized === "" || normalized === ".") {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
return normalized + "/";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/handlers.ts
|
|
11
89
|
var mediaHandlerConfig = {
|
|
12
90
|
api: {
|
|
13
91
|
bodyParser: false
|
|
@@ -38,10 +116,17 @@ var createMediaHandler = (config, options) => {
|
|
|
38
116
|
switch (req.method) {
|
|
39
117
|
case "GET":
|
|
40
118
|
if (req.query.key) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
119
|
+
const requestedExpiresIn = Number(req.query.expiresIn);
|
|
120
|
+
const expiresIn = Number.isFinite(requestedExpiresIn) && requestedExpiresIn > 0 ? Math.min(requestedExpiresIn, 3600) : 3600;
|
|
121
|
+
const rawKey = Array.isArray(req.query.key) ? req.query.key[0] : req.query.key;
|
|
122
|
+
let s3_key;
|
|
123
|
+
try {
|
|
124
|
+
s3_key = resolveKey(mediaRoot, rawKey, { decode: false });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e instanceof MediaKeyError) {
|
|
127
|
+
return res.status(400).json({ message: e.message });
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
45
130
|
}
|
|
46
131
|
if (await keyExists(client, bucket, s3_key)) {
|
|
47
132
|
return res.status(400).json({ message: "key already exists" });
|
|
@@ -56,7 +141,7 @@ var createMediaHandler = (config, options) => {
|
|
|
56
141
|
}
|
|
57
142
|
return listMedia(req, res, client, bucket, mediaRoot, cdnUrl);
|
|
58
143
|
case "DELETE":
|
|
59
|
-
return deleteAsset(req, res, client, bucket);
|
|
144
|
+
return deleteAsset(req, res, client, bucket, mediaRoot);
|
|
60
145
|
default:
|
|
61
146
|
res.end(404);
|
|
62
147
|
}
|
|
@@ -85,12 +170,20 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
|
|
|
85
170
|
limit = 500,
|
|
86
171
|
offset
|
|
87
172
|
} = req.query;
|
|
88
|
-
let prefix
|
|
89
|
-
|
|
173
|
+
let prefix;
|
|
174
|
+
try {
|
|
175
|
+
prefix = resolveDirectory(directory);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (e instanceof MediaKeyError) {
|
|
178
|
+
res.status(400).json({ message: e.message });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
90
183
|
const params = {
|
|
91
184
|
Bucket: bucket,
|
|
92
185
|
Delimiter: "/",
|
|
93
|
-
Prefix: mediaRoot ?
|
|
186
|
+
Prefix: mediaRoot ? path2.join(mediaRoot, prefix) : prefix,
|
|
94
187
|
Marker: offset?.toString(),
|
|
95
188
|
MaxKeys: directory && !offset ? +limit + 1 : +limit
|
|
96
189
|
};
|
|
@@ -105,8 +198,8 @@ async function listMedia(req, res, client, bucket, mediaRoot, cdnUrl) {
|
|
|
105
198
|
items.push({
|
|
106
199
|
id: Prefix,
|
|
107
200
|
type: "dir",
|
|
108
|
-
filename:
|
|
109
|
-
directory:
|
|
201
|
+
filename: path2.basename(strippedPrefix),
|
|
202
|
+
directory: path2.dirname(strippedPrefix)
|
|
110
203
|
});
|
|
111
204
|
});
|
|
112
205
|
items.push(
|
|
@@ -133,9 +226,18 @@ var findErrorMessage = (e) => {
|
|
|
133
226
|
if (e.error && e.error.message) return e.error.message;
|
|
134
227
|
return "an error occurred";
|
|
135
228
|
};
|
|
136
|
-
async function deleteAsset(req, res, client, bucket) {
|
|
229
|
+
async function deleteAsset(req, res, client, bucket, mediaRoot) {
|
|
137
230
|
const { media } = req.query;
|
|
138
|
-
const [,
|
|
231
|
+
const [, rawKey] = media;
|
|
232
|
+
let objectKey;
|
|
233
|
+
try {
|
|
234
|
+
objectKey = resolveKey(mediaRoot, rawKey, { decode: false });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e instanceof MediaKeyError) {
|
|
237
|
+
return res.status(400).json({ message: e.message });
|
|
238
|
+
}
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
139
241
|
const params = {
|
|
140
242
|
Bucket: bucket,
|
|
141
243
|
Key: objectKey
|
|
@@ -183,8 +285,8 @@ var getUploadUrl = async (bucket, key, expiresIn, client) => {
|
|
|
183
285
|
function getS3ToTinaFunc(cdnUrl, mediaRoot) {
|
|
184
286
|
return function s3ToTina(file) {
|
|
185
287
|
const strippedKey = stripMediaRoot(mediaRoot, file.Key);
|
|
186
|
-
const filename =
|
|
187
|
-
const directory =
|
|
288
|
+
const filename = path2.basename(strippedKey);
|
|
289
|
+
const directory = path2.dirname(strippedKey) + "/";
|
|
188
290
|
const src = cdnUrl + file.Key;
|
|
189
291
|
return {
|
|
190
292
|
id: file.Key,
|
|
@@ -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-s3",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "23.0.
|
|
4
|
+
"version": "23.0.4",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
7
7
|
"files": [
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"react": "^18.3.1",
|
|
28
28
|
"react-dom": "^18.3.1",
|
|
29
29
|
"typescript": "^5.7.3",
|
|
30
|
-
"@tinacms/scripts": "1.6.
|
|
31
|
-
"tinacms": "3.9.
|
|
30
|
+
"@tinacms/scripts": "1.6.2",
|
|
31
|
+
"tinacms": "3.9.4"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"tinacms": "3.9.
|
|
34
|
+
"tinacms": "3.9.4"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"registry": "https://registry.npmjs.org"
|