@zintrust/core 0.1.2 → 0.1.3
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/package.json +15 -1
- package/src/security/XssProtection.d.ts.map +1 -1
- package/src/security/XssProtection.js +6 -2
- package/src/templates/project/basic/routes/api.ts.tpl +2 -0
- package/src/templates/project/basic/routes/storage.ts.tpl +42 -0
- package/src/tools/http/Http.d.ts +0 -2
- package/src/tools/http/Http.d.ts.map +1 -1
- package/src/tools/http/Http.js +0 -2
- package/src/tools/storage/LocalSignedUrl.d.ts +12 -0
- package/src/tools/storage/LocalSignedUrl.d.ts.map +1 -0
- package/src/tools/storage/LocalSignedUrl.js +108 -0
- package/src/tools/storage/drivers/Local.d.ts +2 -1
- package/src/tools/storage/drivers/Local.d.ts.map +1 -1
- package/src/tools/storage/drivers/Local.js +39 -13
- package/src/tools/storage/index.d.ts +1 -1
- package/src/tools/storage/index.d.ts.map +1 -1
- package/src/tools/storage/index.js +4 -5
- package/src/tools/storage/testing.d.ts +1 -1
- package/src/tools/storage/testing.d.ts.map +1 -1
- package/src/tools/storage/testing.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zintrust/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Production-grade TypeScript backend framework for JavaScript",
|
|
5
5
|
"homepage": "https://zintrust.com",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "src/index.js",
|
|
15
15
|
"types": "src/index.d.ts",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"bcrypt": "^6.0.0",
|
|
18
|
+
"better-sqlite3": "^12.5.0",
|
|
19
|
+
"chalk": "^5.6.2",
|
|
20
|
+
"commander": "^14.0.2",
|
|
21
|
+
"inquirer": "^13.1.0",
|
|
22
|
+
"jsonwebtoken": "^9.0.3",
|
|
23
|
+
"tsx": "^4.21.0"
|
|
24
|
+
},
|
|
25
|
+
"overrides": {
|
|
26
|
+
"node-forge": "1.3.3",
|
|
27
|
+
"cross-spawn": "^7.0.5",
|
|
28
|
+
"glob": "^11.1.0"
|
|
29
|
+
},
|
|
16
30
|
"bin": {
|
|
17
31
|
"zintrust": "bin/zintrust.js",
|
|
18
32
|
"zin": "bin/zin.js",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"XssProtection.d.ts","sourceRoot":"","sources":["../../../src/security/XssProtection.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"XssProtection.d.ts","sourceRoot":"","sources":["../../../src/security/XssProtection.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AA2SH;;GAEG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,OAAO,KAAG,MAGzC,CAAC;AAEF,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC;CAClC;AAED;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,cAO1B,CAAC"}
|
|
@@ -36,8 +36,12 @@ const sanitizeHtml = (html) => {
|
|
|
36
36
|
// Remove iframe, object, embed, and base tags
|
|
37
37
|
sanitized = sanitized.replaceAll(/<(?:iframe|object|embed|base)\b[\s\S]*?>/gi, '');
|
|
38
38
|
sanitized = sanitized.replaceAll(/<\/(?:iframe|object|embed|base)>/gi, '');
|
|
39
|
-
// Remove event handlers (on*)
|
|
40
|
-
|
|
39
|
+
// Remove event handlers (on*). Re-apply until stable to avoid incomplete multi-character sanitization.
|
|
40
|
+
let previousSanitized;
|
|
41
|
+
do {
|
|
42
|
+
previousSanitized = sanitized;
|
|
43
|
+
sanitized = sanitized.replaceAll(/\bon\w+\s*=\s*(?:'[^']*'|"[^"]*"|`[^`]*`|[^\s>]*)/gi, '');
|
|
44
|
+
} while (sanitized !== previousSanitized);
|
|
41
45
|
// Remove dangerous protocols in URL-bearing attributes.
|
|
42
46
|
// This uses the same protocol normalization logic as encodeHref to prevent obfuscations like:
|
|
43
47
|
// href="javascript:..." or href="java\nscript:..." or href="%6a%61..."
|
|
@@ -7,6 +7,7 @@ import { UserController } from '@app/Controllers/UserController';
|
|
|
7
7
|
import { Env } from '@config/env';
|
|
8
8
|
import { registerBroadcastRoutes } from '@routes/broadcast';
|
|
9
9
|
import { registerHealthRoutes } from '@routes/health';
|
|
10
|
+
import { registerStorageRoutes } from '@routes/storage';
|
|
10
11
|
import { type IRouter, Router } from '@routing/Router';
|
|
11
12
|
|
|
12
13
|
export function registerRoutes(router: IRouter): void {
|
|
@@ -23,6 +24,7 @@ function registerPublicRoutes(router: IRouter): void {
|
|
|
23
24
|
registerRootRoute(router);
|
|
24
25
|
registerHealthRoutes(router);
|
|
25
26
|
registerBroadcastRoutes(router);
|
|
27
|
+
registerStorageRoutes(router);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
function registerRootRoute(router: IRouter): void {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { HTTP_HEADERS } from '@config/constants';
|
|
2
|
+
import { Env } from '@config/env';
|
|
3
|
+
import { type IRouter, Router } from '@routing/Router';
|
|
4
|
+
import { LocalSignedUrl } from '@storage/LocalSignedUrl';
|
|
5
|
+
import { Storage } from '@storage/index';
|
|
6
|
+
|
|
7
|
+
export function registerStorageRoutes(router: IRouter): void {
|
|
8
|
+
Router.get(router, '/storage/download', async (req, res) => {
|
|
9
|
+
const tokenRaw = req.getQueryParam('token');
|
|
10
|
+
const token = typeof tokenRaw === 'string' ? tokenRaw : '';
|
|
11
|
+
|
|
12
|
+
if (token.trim() === '') {
|
|
13
|
+
res.setStatus(400).json({ message: 'Missing token' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const appKey = Env.get('APP_KEY', '');
|
|
18
|
+
if (appKey.trim() === '') {
|
|
19
|
+
res.setStatus(500).json({ message: 'Storage signing is not configured' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const payload = LocalSignedUrl.verifyToken(token, appKey);
|
|
25
|
+
|
|
26
|
+
// Only local disk is supported by this route.
|
|
27
|
+
if (payload.disk !== 'local') {
|
|
28
|
+
res.setStatus(400).json({ message: 'Unsupported disk' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const contents = await Storage.get('local', payload.key);
|
|
33
|
+
|
|
34
|
+
res.setHeader(HTTP_HEADERS.CONTENT_TYPE, 'application/octet-stream');
|
|
35
|
+
res.setStatus(200).send(contents);
|
|
36
|
+
} catch {
|
|
37
|
+
res.setStatus(403).json({ message: 'Invalid or expired token' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default registerStorageRoutes;
|
package/src/tools/http/Http.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Http Client - Fluent HTTP request builder
|
|
3
|
-
* Laravel-style HTTP client for making authenticated requests
|
|
4
3
|
*
|
|
5
4
|
* Usage:
|
|
6
5
|
* await HttpClient.get('https://api.example.com/users').withAuth(token).send();
|
|
@@ -23,7 +22,6 @@ export interface IHttpRequest {
|
|
|
23
22
|
}
|
|
24
23
|
/**
|
|
25
24
|
* HTTP Client - Sealed namespace for making HTTP requests
|
|
26
|
-
* Provides Laravel-style fluent API
|
|
27
25
|
*/
|
|
28
26
|
export declare const HttpClient: Readonly<{
|
|
29
27
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../../../../src/tools/http/Http.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../../../../src/tools/http/Http.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAElF,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAAC;IACtD,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,YAAY,CAAC;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,YAAY,CAAC;IACnE,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAAC;IAChE,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,CAAC;IACtC,MAAM,IAAI,YAAY,CAAC;IACvB,MAAM,IAAI,YAAY,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;CAChC;AAiJD;;GAEG;AACH,eAAO,MAAM,UAAU;IACrB;;OAEG;aACM,MAAM,GAAG,YAAY;IAI9B;;OAEG;cACO,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY;IAQ/D;;OAEG;aACM,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY;IAQ9D;;OAEG;eACQ,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY;IAQhE;;OAEG;gBACS,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY;EAOjE,CAAC;AAEH,eAAe,UAAU,CAAC"}
|
package/src/tools/http/Http.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Http Client - Fluent HTTP request builder
|
|
3
|
-
* Laravel-style HTTP client for making authenticated requests
|
|
4
3
|
*
|
|
5
4
|
* Usage:
|
|
6
5
|
* await HttpClient.get('https://api.example.com/users').withAuth(token).send();
|
|
@@ -118,7 +117,6 @@ const createRequestBuilder = (method, url, initialBody) => {
|
|
|
118
117
|
};
|
|
119
118
|
/**
|
|
120
119
|
* HTTP Client - Sealed namespace for making HTTP requests
|
|
121
|
-
* Provides Laravel-style fluent API
|
|
122
120
|
*/
|
|
123
121
|
export const HttpClient = Object.freeze({
|
|
124
122
|
/**
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type LocalSignedUrlPayload = {
|
|
2
|
+
disk: 'local';
|
|
3
|
+
key: string;
|
|
4
|
+
exp: number;
|
|
5
|
+
method: 'GET';
|
|
6
|
+
};
|
|
7
|
+
export declare const LocalSignedUrl: Readonly<{
|
|
8
|
+
createToken(payload: LocalSignedUrlPayload, secret: string): string;
|
|
9
|
+
verifyToken(token: string, secret: string, nowMs?: number): LocalSignedUrlPayload;
|
|
10
|
+
}>;
|
|
11
|
+
export default LocalSignedUrl;
|
|
12
|
+
//# sourceMappingURL=LocalSignedUrl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LocalSignedUrl.d.ts","sourceRoot":"","sources":["../../../../src/tools/storage/LocalSignedUrl.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,KAAK,CAAC;CACf,CAAC;AA8DF,eAAO,MAAM,cAAc;yBACJ,qBAAqB,UAAU,MAAM,GAAG,MAAM;uBAyBhD,MAAM,UAAU,MAAM,UAAS,MAAM,GAAgB,qBAAqB;EA2C7F,CAAC;AAEH,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ErrorFactory } from '../../exceptions/ZintrustError';
|
|
2
|
+
import { createHmac } from '../../node-singletons/crypto';
|
|
3
|
+
const base64UrlEncode = (value) => {
|
|
4
|
+
const base64 = Buffer.isBuffer(value)
|
|
5
|
+
? value.toString('base64')
|
|
6
|
+
: Buffer.from(value).toString('base64');
|
|
7
|
+
// replace characters used in regular base64 and remove any trailing '=' padding
|
|
8
|
+
let result = base64.replaceAll('+', '-').replaceAll('/', '_');
|
|
9
|
+
// Remove trailing '=' characters without using a regex to avoid potential super-linear backtracking.
|
|
10
|
+
while (result.endsWith('=')) {
|
|
11
|
+
result = result.slice(0, -1);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
const base64UrlDecodeToString = (value) => {
|
|
16
|
+
const padded = value + '==='.slice((value.length + 3) % 4);
|
|
17
|
+
const base64 = padded.replaceAll('-', '+').replaceAll('_', '/');
|
|
18
|
+
return Buffer.from(base64, 'base64').toString('utf8');
|
|
19
|
+
};
|
|
20
|
+
const timingSafeEquals = (a, b) => {
|
|
21
|
+
if (a.length !== b.length)
|
|
22
|
+
return false;
|
|
23
|
+
let result = 0;
|
|
24
|
+
for (let i = 0; i < a.length; i++) {
|
|
25
|
+
result |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0);
|
|
26
|
+
}
|
|
27
|
+
return result === 0;
|
|
28
|
+
};
|
|
29
|
+
const assertValidKey = (key) => {
|
|
30
|
+
if (key.trim() === '') {
|
|
31
|
+
throw ErrorFactory.createValidationError('Local signed url: key is required');
|
|
32
|
+
}
|
|
33
|
+
// Hard fail on obvious traversal / absolute paths.
|
|
34
|
+
// Keep this strict; keys should be relative like `uploads/a.png`.
|
|
35
|
+
if (key.startsWith('/') || key.startsWith('\\')) {
|
|
36
|
+
throw ErrorFactory.createValidationError('Local signed url: key must be relative');
|
|
37
|
+
}
|
|
38
|
+
const segments = key.split(/[/\\]+/g);
|
|
39
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
40
|
+
throw ErrorFactory.createValidationError('Local signed url: invalid key');
|
|
41
|
+
}
|
|
42
|
+
if (key.includes('\0')) {
|
|
43
|
+
throw ErrorFactory.createValidationError('Local signed url: invalid key');
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const sign = (payloadEncoded, secret) => {
|
|
47
|
+
if (secret.trim() === '') {
|
|
48
|
+
throw ErrorFactory.createConfigError('Local signed url: signing secret not configured (set APP_KEY)');
|
|
49
|
+
}
|
|
50
|
+
const signature = createHmac('sha256', secret).update(payloadEncoded).digest();
|
|
51
|
+
return base64UrlEncode(signature);
|
|
52
|
+
};
|
|
53
|
+
export const LocalSignedUrl = Object.freeze({
|
|
54
|
+
createToken(payload, secret) {
|
|
55
|
+
assertValidKey(payload.key);
|
|
56
|
+
if (payload.disk !== 'local') {
|
|
57
|
+
throw ErrorFactory.createValidationError('Local signed url: unsupported disk', {
|
|
58
|
+
disk: payload.disk,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (payload.method !== 'GET') {
|
|
62
|
+
throw ErrorFactory.createValidationError('Local signed url: unsupported method', {
|
|
63
|
+
method: payload.method,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(payload.exp) || payload.exp <= 0) {
|
|
67
|
+
throw ErrorFactory.createValidationError('Local signed url: invalid expiration');
|
|
68
|
+
}
|
|
69
|
+
const payloadEncoded = base64UrlEncode(JSON.stringify(payload));
|
|
70
|
+
const signatureEncoded = sign(payloadEncoded, secret);
|
|
71
|
+
return `${payloadEncoded}.${signatureEncoded}`;
|
|
72
|
+
},
|
|
73
|
+
verifyToken(token, secret, nowMs = Date.now()) {
|
|
74
|
+
if (token.trim() === '') {
|
|
75
|
+
throw ErrorFactory.createValidationError('Local signed url: token is required');
|
|
76
|
+
}
|
|
77
|
+
const parts = token.split('.');
|
|
78
|
+
if (parts.length !== 2) {
|
|
79
|
+
throw ErrorFactory.createValidationError('Local signed url: malformed token');
|
|
80
|
+
}
|
|
81
|
+
const payloadEncoded = parts[0] ?? '';
|
|
82
|
+
const signatureEncoded = parts[1] ?? '';
|
|
83
|
+
const expectedSignature = sign(payloadEncoded, secret);
|
|
84
|
+
if (!timingSafeEquals(signatureEncoded, expectedSignature)) {
|
|
85
|
+
throw ErrorFactory.createSecurityError('Local signed url: invalid signature');
|
|
86
|
+
}
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = JSON.parse(base64UrlDecodeToString(payloadEncoded));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw ErrorFactory.createValidationError('Local signed url: invalid payload', { error: err });
|
|
93
|
+
}
|
|
94
|
+
const p = payload;
|
|
95
|
+
if (p.disk !== 'local' ||
|
|
96
|
+
typeof p.key !== 'string' ||
|
|
97
|
+
typeof p.exp !== 'number' ||
|
|
98
|
+
p.method !== 'GET') {
|
|
99
|
+
throw ErrorFactory.createValidationError('Local signed url: invalid payload');
|
|
100
|
+
}
|
|
101
|
+
assertValidKey(p.key);
|
|
102
|
+
if (p.exp < nowMs) {
|
|
103
|
+
throw ErrorFactory.createSecurityError('Local signed url: token expired');
|
|
104
|
+
}
|
|
105
|
+
return p;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
export default LocalSignedUrl;
|
|
@@ -3,12 +3,13 @@ export type LocalConfig = {
|
|
|
3
3
|
url?: string;
|
|
4
4
|
};
|
|
5
5
|
export declare const LocalDriver: Readonly<{
|
|
6
|
+
resolveKey(config: LocalConfig, key: string): string;
|
|
6
7
|
put(config: LocalConfig, key: string, content: string | Buffer): Promise<string>;
|
|
7
8
|
get(config: LocalConfig, key: string): Promise<Buffer>;
|
|
8
9
|
exists(config: LocalConfig, key: string): Promise<boolean>;
|
|
9
10
|
delete(config: LocalConfig, key: string): Promise<void>;
|
|
10
11
|
url(config: LocalConfig, key: string): string | undefined;
|
|
11
|
-
tempUrl(config: LocalConfig, key: string,
|
|
12
|
+
tempUrl(config: LocalConfig, key: string, options?: {
|
|
12
13
|
expiresIn?: number;
|
|
13
14
|
method?: "GET" | "PUT";
|
|
14
15
|
}): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Local.d.ts","sourceRoot":"","sources":["../../../../../src/tools/storage/drivers/Local.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Local.d.ts","sourceRoot":"","sources":["../../../../../src/tools/storage/drivers/Local.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,eAAO,MAAM,WAAW;uBACH,WAAW,OAAO,MAAM,GAAG,MAAM;gBAuBlC,WAAW,OAAO,MAAM,WAAW,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;gBAapE,WAAW,OAAO,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;mBASvC,WAAW,OAAO,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;mBAU3C,WAAW,OAAO,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;gBASjD,WAAW,OAAO,MAAM,GAAG,MAAM,GAAG,SAAS;oBAM/C,WAAW,OACd,MAAM,YACD;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,KAAK,GAAG,KAAK,CAAA;KAAE,GACvD,MAAM;EA4BT,CAAC;AAEH,eAAe,WAAW,CAAC"}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
import { Env } from '../../../config/env';
|
|
1
2
|
import { ErrorFactory } from '../../../exceptions/ZintrustError';
|
|
2
3
|
import { fsPromises as fs } from '../../../node-singletons/fs';
|
|
3
4
|
import * as path from '../../../node-singletons/path';
|
|
5
|
+
import { LocalSignedUrl } from '../LocalSignedUrl';
|
|
4
6
|
export const LocalDriver = Object.freeze({
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (!root || root.trim() === '') {
|
|
7
|
+
resolveKey(config, key) {
|
|
8
|
+
if (!config.root || config.root.trim() === '') {
|
|
8
9
|
throw ErrorFactory.createConfigError('Local storage root is not configured');
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
if (key.trim() === '') {
|
|
12
|
+
throw ErrorFactory.createValidationError('Local storage: key is required');
|
|
13
|
+
}
|
|
14
|
+
if (key.startsWith('/') || key.startsWith('\\')) {
|
|
15
|
+
throw ErrorFactory.createValidationError('Local storage: key must be relative');
|
|
16
|
+
}
|
|
17
|
+
const segments = key.split(/[/\\]+/g);
|
|
18
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
19
|
+
throw ErrorFactory.createValidationError('Local storage: invalid key');
|
|
20
|
+
}
|
|
21
|
+
const fullPath = path.resolve(path.join(config.root, key));
|
|
22
|
+
return fullPath;
|
|
23
|
+
},
|
|
24
|
+
async put(config, key, content) {
|
|
25
|
+
const fullPath = LocalDriver.resolveKey(config, key);
|
|
11
26
|
const dir = path.dirname(fullPath);
|
|
12
27
|
await fs.mkdir(dir, { recursive: true });
|
|
13
28
|
if (typeof content === 'string') {
|
|
@@ -19,7 +34,7 @@ export const LocalDriver = Object.freeze({
|
|
|
19
34
|
return fullPath;
|
|
20
35
|
},
|
|
21
36
|
async get(config, key) {
|
|
22
|
-
const fullPath =
|
|
37
|
+
const fullPath = LocalDriver.resolveKey(config, key);
|
|
23
38
|
try {
|
|
24
39
|
return await fs.readFile(fullPath);
|
|
25
40
|
}
|
|
@@ -28,7 +43,7 @@ export const LocalDriver = Object.freeze({
|
|
|
28
43
|
}
|
|
29
44
|
},
|
|
30
45
|
async exists(config, key) {
|
|
31
|
-
const fullPath =
|
|
46
|
+
const fullPath = LocalDriver.resolveKey(config, key);
|
|
32
47
|
try {
|
|
33
48
|
await fs.access(fullPath);
|
|
34
49
|
return true;
|
|
@@ -38,13 +53,12 @@ export const LocalDriver = Object.freeze({
|
|
|
38
53
|
}
|
|
39
54
|
},
|
|
40
55
|
async delete(config, key) {
|
|
41
|
-
const fullPath =
|
|
56
|
+
const fullPath = LocalDriver.resolveKey(config, key);
|
|
42
57
|
try {
|
|
43
58
|
await fs.unlink(fullPath);
|
|
44
59
|
}
|
|
45
|
-
catch
|
|
60
|
+
catch {
|
|
46
61
|
// ignore not found
|
|
47
|
-
void err; // NOSONAR
|
|
48
62
|
}
|
|
49
63
|
},
|
|
50
64
|
url(config, key) {
|
|
@@ -52,12 +66,24 @@ export const LocalDriver = Object.freeze({
|
|
|
52
66
|
return undefined;
|
|
53
67
|
return `${config.url.replace(/\/$/, '')}/${key}`;
|
|
54
68
|
},
|
|
55
|
-
tempUrl(config, key,
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
tempUrl(config, key, options) {
|
|
70
|
+
if (options?.method === 'PUT') {
|
|
71
|
+
throw ErrorFactory.createValidationError('Local storage: tempUrl does not support PUT');
|
|
72
|
+
}
|
|
73
|
+
if (config?.url === undefined || config.url.trim() === '') {
|
|
58
74
|
throw ErrorFactory.createConfigError('Local storage: url is not configured (set STORAGE_URL)');
|
|
59
75
|
}
|
|
60
|
-
|
|
76
|
+
const appKey = Env.get('APP_KEY', '');
|
|
77
|
+
if (appKey.trim() === '') {
|
|
78
|
+
throw ErrorFactory.createConfigError('Local storage: APP_KEY is required for signed tempUrl()');
|
|
79
|
+
}
|
|
80
|
+
// Ensure key is safe before embedding in a signed token.
|
|
81
|
+
LocalDriver.resolveKey(config, key);
|
|
82
|
+
const expiresInMs = Math.max(1, options?.expiresIn ?? 60_000);
|
|
83
|
+
const exp = Date.now() + expiresInMs;
|
|
84
|
+
const token = LocalSignedUrl.createToken({ disk: 'local', key, exp, method: 'GET' }, appKey);
|
|
85
|
+
const baseUrl = config.url.replace(/\/$/, '');
|
|
86
|
+
return `${baseUrl}/download?token=${encodeURIComponent(token)}`;
|
|
61
87
|
},
|
|
62
88
|
});
|
|
63
89
|
export default LocalDriver;
|
|
@@ -18,7 +18,7 @@ export declare const Storage: Readonly<{
|
|
|
18
18
|
exists(disk: string | undefined, path: string): Promise<boolean>;
|
|
19
19
|
delete(disk: string | undefined, path: string): Promise<void>;
|
|
20
20
|
url(disk: string | undefined, path: string): string;
|
|
21
|
-
tempUrl(disk: string | undefined, path: string, options?: TempUrlOptions): string
|
|
21
|
+
tempUrl(disk: string | undefined, path: string, options?: TempUrlOptions): Promise<string>;
|
|
22
22
|
}>;
|
|
23
23
|
export default Storage;
|
|
24
24
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/tools/storage/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/tools/storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAGxD,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAE/C,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,IAAI,GAAG,KAAK,GAAG,IAAI,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,OAAO,WAAW,GAAG,OAAO,QAAQ,GAAG,OAAO,QAAQ,GAAG,OAAO,SAAS,CAAC;IAClF,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,KAAK,cAAc,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,GAAG,KAAK,CAAA;CAAE,CAAC;AAsCrE,eAAO,MAAM,OAAO;mBACH,MAAM,GAAG,WAAW;cAqBnB,MAAM,GAAG,SAAS,QAAQ,MAAM,YAAY,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;cAW7E,MAAM,GAAG,SAAS,QAAQ,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;iBAW/C,MAAM,GAAG,SAAS,QAAQ,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;iBASnD,MAAM,GAAG,SAAS,QAAQ,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;cASzD,MAAM,GAAG,SAAS,QAAQ,MAAM,GAAG,MAAM;kBAY/B,MAAM,GAAG,SAAS,QAAQ,MAAM,YAAY,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC;EAqBhG,CAAC;AAEH,eAAe,OAAO,CAAC"}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import { GcsDriver } from '../../tools/storage/drivers/Gcs';
|
|
1
2
|
import { storageConfig } from '../../config/storage';
|
|
2
3
|
import { ErrorFactory } from '../../exceptions/ZintrustError';
|
|
3
|
-
// import { GcsDriver } from './drivers/Gcs';
|
|
4
|
-
import { GcsDriver } from '../../tools/storage/drivers/Gcs';
|
|
5
4
|
import { LocalDriver } from './drivers/Local';
|
|
6
5
|
import { R2Driver } from './drivers/R2';
|
|
7
6
|
import { S3Driver } from './drivers/S3';
|
|
@@ -71,7 +70,7 @@ export const Storage = Object.freeze({
|
|
|
71
70
|
if (typeof driver.get !== 'function') {
|
|
72
71
|
throw ErrorFactory.createConfigError('Storage: driver is missing get()');
|
|
73
72
|
}
|
|
74
|
-
return
|
|
73
|
+
return driver.get(d.config, path);
|
|
75
74
|
},
|
|
76
75
|
async exists(disk, path) {
|
|
77
76
|
const d = Storage.getDisk(disk);
|
|
@@ -96,11 +95,11 @@ export const Storage = Object.freeze({
|
|
|
96
95
|
}
|
|
97
96
|
return url;
|
|
98
97
|
},
|
|
99
|
-
tempUrl(disk, path, options) {
|
|
98
|
+
async tempUrl(disk, path, options) {
|
|
100
99
|
const d = Storage.getDisk(disk);
|
|
101
100
|
const driver = d.driver;
|
|
102
101
|
if (typeof driver.tempUrl === 'function') {
|
|
103
|
-
return driver.tempUrl(d.config, path, options);
|
|
102
|
+
return Promise.resolve(driver.tempUrl(d.config, path, options));
|
|
104
103
|
}
|
|
105
104
|
const url = typeof driver.url === 'function' ? driver.url(d.config, path) : undefined;
|
|
106
105
|
if (typeof url !== 'string' || url.trim() === '') {
|
|
@@ -13,7 +13,7 @@ export declare const FakeStorage: Readonly<{
|
|
|
13
13
|
tempUrl(disk: string, path: string, options?: {
|
|
14
14
|
expiresIn?: number;
|
|
15
15
|
method?: "GET" | "PUT";
|
|
16
|
-
}): string
|
|
16
|
+
}): Promise<string>;
|
|
17
17
|
assertExists(disk: string, path: string): void;
|
|
18
18
|
assertMissing(disk: string, path: string): void;
|
|
19
19
|
getPuts(): FakePut[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../../../src/tools/storage/testing.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAIF,eAAO,MAAM,WAAW;WACT,KAAK,CAAC,OAAO,CAAC;cAEX,MAAM,QAAQ,MAAM,YAAY,MAAM;cAM5C,MAAM,QAAQ,MAAM;iBAMjB,MAAM,QAAQ,MAAM;iBAId,MAAM,QAAQ,MAAM;cAW7B,MAAM,QAAQ,MAAM;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../../../src/tools/storage/testing.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAIF,eAAO,MAAM,WAAW;WACT,KAAK,CAAC,OAAO,CAAC;cAEX,MAAM,QAAQ,MAAM,YAAY,MAAM;cAM5C,MAAM,QAAQ,MAAM;iBAMjB,MAAM,QAAQ,MAAM;iBAId,MAAM,QAAQ,MAAM;cAW7B,MAAM,QAAQ,MAAM;kBAMtB,MAAM,QACN,MAAM,YACF;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,KAAK,GAAG,KAAK,CAAA;KAAE;uBAQvC,MAAM,QAAQ,MAAM;wBAMnB,MAAM,QAAQ,MAAM;;;EAgBxC,CAAC;AAEH,eAAe,WAAW,CAAC"}
|
|
@@ -25,7 +25,7 @@ export const FakeStorage = Object.freeze({
|
|
|
25
25
|
return `fake://${disk}/${path}`;
|
|
26
26
|
},
|
|
27
27
|
// tempUrl builder is a convenience: matches the real API shape
|
|
28
|
-
tempUrl(disk, path, options) {
|
|
28
|
+
async tempUrl(disk, path, options) {
|
|
29
29
|
const expiresIn = options?.expiresIn ?? 900;
|
|
30
30
|
const method = options?.method ?? 'GET';
|
|
31
31
|
return `fake://${disk}/${path}?expiresIn=${expiresIn}&method=${method}`;
|