@usequark/quark-create-app 1.25.14 → 1.25.16
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.16",
|
|
4
4
|
"description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quark",
|
|
@@ -59,9 +59,8 @@
|
|
|
59
59
|
"sync-templates:check": "node scripts/sync-templates.js --check",
|
|
60
60
|
"regen-migration": "node scripts/regenerate-template-migration.js",
|
|
61
61
|
"smoke:published": "node scripts/smoke-published.js",
|
|
62
|
-
"test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
|
|
62
|
+
"test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js' && node --test test-flags.js",
|
|
63
63
|
"test:build": "node test-build.js",
|
|
64
|
-
"test:e2e": "node test-e2e.js",
|
|
65
64
|
"test:e2e:full": "node test-e2e-full.js",
|
|
66
65
|
"test:integration": "node test-integration.js",
|
|
67
66
|
"test:flags": "node --test test-flags.js",
|
package/src/index.js
CHANGED
|
@@ -705,8 +705,11 @@ program
|
|
|
705
705
|
` as its own repository, or\n` +
|
|
706
706
|
` • scaffold into a temporary folder and move the files to\n` +
|
|
707
707
|
` the repository root before the first commit.\n\n` +
|
|
708
|
-
` Git initialization
|
|
709
|
-
|
|
708
|
+
` Git initialization ${
|
|
709
|
+
process.env.QUARK_FORCE_GIT_INIT === "true"
|
|
710
|
+
? "will run anyway (QUARK_FORCE_GIT_INIT=true)."
|
|
711
|
+
: "will be skipped to avoid creating a\n nested repository."
|
|
712
|
+
}\n\n` +
|
|
710
713
|
` GitHub only runs workflows stored at the repository root, so\n` +
|
|
711
714
|
` <project>/.github/workflows/*.yml will be ignored by GitHub\n` +
|
|
712
715
|
` Actions.\n`,
|
|
@@ -1577,7 +1580,11 @@ STORAGE_PROVIDER=local
|
|
|
1577
1580
|
// Step 11: Initialize git repository
|
|
1578
1581
|
if (process.env.QUARK_SKIP_GIT_INIT === "true") {
|
|
1579
1582
|
console.log(chalk.dim("\n · Skipping git initialization"));
|
|
1580
|
-
} else if (enclosingRepo) {
|
|
1583
|
+
} else if (enclosingRepo && process.env.QUARK_FORCE_GIT_INIT !== "true") {
|
|
1584
|
+
// `initializeGit()` also checks QUARK_FORCE_GIT_INIT internally,
|
|
1585
|
+
// but it is only reached in the else branch below — short-
|
|
1586
|
+
// circuiting here on `enclosingRepo` alone made the documented
|
|
1587
|
+
// force flag a no-op.
|
|
1581
1588
|
console.log(
|
|
1582
1589
|
chalk.dim(
|
|
1583
1590
|
"\n · Skipping git initialization (inside an existing repository)",
|
|
@@ -59,17 +59,43 @@ export async function GET(_request, { params }) {
|
|
|
59
59
|
// max-age bounds that window instead.
|
|
60
60
|
headers.set("Cache-Control", "public, max-age=3600");
|
|
61
61
|
|
|
62
|
-
// Inline display
|
|
63
|
-
|
|
62
|
+
// Inline display is limited to raster formats. SVG is deliberately not
|
|
63
|
+
// among them: an SVG is an XML document that can carry <script>, this
|
|
64
|
+
// endpoint is public and same-origin, and CSP `default-src 'self'`
|
|
65
|
+
// permits those scripts to run — so a stored SVG served inline executes
|
|
66
|
+
// in the app's own origin (stored XSS; audit item 8). SVG therefore
|
|
67
|
+
// downloads as an attachment, and its response additionally carries a
|
|
68
|
+
// sandbox CSP so a browser that ignores the disposition still cannot
|
|
69
|
+
// run scripts, fetch, or embed the document.
|
|
70
|
+
const INLINE_SAFE_TYPES = new Set([
|
|
71
|
+
"image/jpeg",
|
|
72
|
+
"image/png",
|
|
73
|
+
"image/gif",
|
|
74
|
+
"image/webp",
|
|
75
|
+
"image/avif",
|
|
76
|
+
]);
|
|
77
|
+
const isInlineSafe = INLINE_SAFE_TYPES.has(record.mimeType);
|
|
78
|
+
const isSvg = record.mimeType === "image/svg+xml";
|
|
64
79
|
const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
|
|
65
80
|
const encodedName = encodeURIComponent(record.originalName);
|
|
66
81
|
headers.set(
|
|
67
82
|
"Content-Disposition",
|
|
68
|
-
|
|
83
|
+
isInlineSafe
|
|
69
84
|
? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
|
|
70
85
|
: `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
|
71
86
|
);
|
|
72
87
|
|
|
88
|
+
if (isSvg) {
|
|
89
|
+
// `sandbox` with no allow-list: no scripts, no same-origin framing,
|
|
90
|
+
// no form submission. `style-src 'unsafe-inline'` keeps simple
|
|
91
|
+
// presentational SVGs viewable; nothing in this policy can execute
|
|
92
|
+
// code.
|
|
93
|
+
headers.set(
|
|
94
|
+
"Content-Security-Policy",
|
|
95
|
+
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
return new Response(body, { status: 200, headers });
|
|
74
100
|
} catch (error) {
|
|
75
101
|
return handleError(error);
|
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { getAllowedOrigins } from "@usequark/quark-config/app-url";
|
|
13
|
+
import { createLogger, createRateLimiter } from "@usequark/quark-core/core";
|
|
13
14
|
import { NextResponse } from "next/server";
|
|
14
15
|
|
|
15
16
|
import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
|
|
16
17
|
import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
|
|
17
18
|
|
|
19
|
+
const logger = createLogger("proxy");
|
|
20
|
+
|
|
18
21
|
// ─── 1. Metrics guard ────────────────────────────────────────────────────────
|
|
19
22
|
|
|
20
23
|
function metricsGuard(request) {
|
|
@@ -38,8 +41,57 @@ function metricsGuard(request) {
|
|
|
38
41
|
return null; // authorised - continue
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
//
|
|
42
|
-
|
|
44
|
+
// Rate limiter: Redis-backed when REDIS_URL is set (multi-instance), otherwise
|
|
45
|
+
// in-memory (single instance). Fails CLOSED by default (see createRateLimiter).
|
|
46
|
+
// The Redis client is created once and reused; ioredis reconnects on its own.
|
|
47
|
+
let rateLimiterPromise = null;
|
|
48
|
+
|
|
49
|
+
// Exported for tests: the Redis-vs-memory backend selection is otherwise only
|
|
50
|
+
// observable through a live Redis connection, which the unit suite doesn't have.
|
|
51
|
+
// The reset clears the memoised instance so a test can flip REDIS_URL and
|
|
52
|
+
// re-select a backend; production code never calls it.
|
|
53
|
+
export function __resetRateLimiterForTests() {
|
|
54
|
+
rateLimiterPromise = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getRateLimiter() {
|
|
58
|
+
if (rateLimiterPromise) return rateLimiterPromise;
|
|
59
|
+
|
|
60
|
+
rateLimiterPromise = (async () => {
|
|
61
|
+
if (!process.env.REDIS_URL) {
|
|
62
|
+
return createRateLimiter({ type: "memory" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const { default: Redis } = await import("ioredis");
|
|
67
|
+
const redisClient = new Redis(process.env.REDIS_URL, {
|
|
68
|
+
maxRetriesPerRequest: 1,
|
|
69
|
+
enableReadyCheck: true,
|
|
70
|
+
retryStrategy(times) {
|
|
71
|
+
return Math.min(times * 50, 2000);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
// ioredis emits an 'error' event on connection trouble; without a
|
|
75
|
+
// listener Node treats it as unhandled and kills the process.
|
|
76
|
+
redisClient.on("error", (err) => {
|
|
77
|
+
logger.error("Redis rate-limiter client error", {
|
|
78
|
+
message: err?.message ?? String(err),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
return createRateLimiter({ type: "redis", redisClient });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error("Redis rate limiter unavailable; using in-memory", {
|
|
84
|
+
message: error?.message ?? String(error),
|
|
85
|
+
});
|
|
86
|
+
// Losing Redis must not lose rate limiting entirely: a per-instance
|
|
87
|
+
// in-memory limiter is still a limiter. It just no longer spans
|
|
88
|
+
// instances, so a distributed attacker gets one budget per instance.
|
|
89
|
+
return createRateLimiter({ type: "memory" });
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
return rateLimiterPromise;
|
|
94
|
+
}
|
|
43
95
|
|
|
44
96
|
/**
|
|
45
97
|
* Resolve the client IP for rate-limit keying.
|
|
@@ -83,61 +135,25 @@ const RATE_LIMIT_CONFIG = {
|
|
|
83
135
|
};
|
|
84
136
|
|
|
85
137
|
/**
|
|
86
|
-
* Rate limiting implementation
|
|
138
|
+
* Rate limiting implementation.
|
|
139
|
+
*
|
|
140
|
+
* Delegates to the core limiter (`createRateLimiter`): Redis-backed when
|
|
141
|
+
* REDIS_URL is set, in-memory otherwise. Its `checkLimit(key, max, window)`
|
|
142
|
+
* returns `{ limited, remaining, resetTime }` — the same shape this proxy
|
|
143
|
+
* already returned, so the response headers below are unchanged.
|
|
87
144
|
*/
|
|
88
|
-
function checkRateLimit(ip, path, method) {
|
|
89
|
-
const
|
|
90
|
-
const key = `${ip}:${path}`;
|
|
91
|
-
|
|
145
|
+
async function checkRateLimit(ip, path, method) {
|
|
146
|
+
const limiter = await getRateLimiter();
|
|
92
147
|
const maxRequests =
|
|
93
148
|
RATE_LIMIT_CONFIG.maxRequests[getRateLimitBucket(path, method)];
|
|
94
149
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Reset if window has passed
|
|
102
|
-
if (now > record.resetTime) {
|
|
103
|
-
record.count = 0;
|
|
104
|
-
record.resetTime = now + RATE_LIMIT_CONFIG.windowMs;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Check if limit exceeded
|
|
108
|
-
if (record.count >= maxRequests) {
|
|
109
|
-
return {
|
|
110
|
-
limited: true,
|
|
111
|
-
resetTime: record.resetTime,
|
|
112
|
-
remaining: 0,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Increment counter
|
|
117
|
-
record.count++;
|
|
118
|
-
rateLimit.set(key, record);
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
limited: false,
|
|
122
|
-
resetTime: record.resetTime,
|
|
123
|
-
remaining: maxRequests - record.count,
|
|
124
|
-
};
|
|
150
|
+
return limiter.checkLimit(
|
|
151
|
+
`${ip}:${path}`,
|
|
152
|
+
maxRequests,
|
|
153
|
+
RATE_LIMIT_CONFIG.windowMs,
|
|
154
|
+
);
|
|
125
155
|
}
|
|
126
156
|
|
|
127
|
-
/**
|
|
128
|
-
* Clean up old rate limit records periodically.
|
|
129
|
-
* `unref()` so the timer never holds the Node process open on shutdown.
|
|
130
|
-
*/
|
|
131
|
-
const rateLimitCleanup = setInterval(() => {
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
for (const [key, record] of rateLimit.entries()) {
|
|
134
|
-
if (now > record.resetTime) {
|
|
135
|
-
rateLimit.delete(key);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}, 60 * 1000); // Clean up every minute
|
|
139
|
-
if (typeof rateLimitCleanup.unref === "function") rateLimitCleanup.unref();
|
|
140
|
-
|
|
141
157
|
/**
|
|
142
158
|
* CORS configuration
|
|
143
159
|
*/
|
|
@@ -225,7 +241,7 @@ export async function proxy(request) {
|
|
|
225
241
|
const ip = getClientIp(request);
|
|
226
242
|
const rateLimitBucket = getRateLimitBucket(pathname, request.method);
|
|
227
243
|
const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
|
|
228
|
-
const rateLimitResult = checkRateLimit(ip, pathname, request.method);
|
|
244
|
+
const rateLimitResult = await checkRateLimit(ip, pathname, request.method);
|
|
229
245
|
|
|
230
246
|
if (rateLimitResult.limited) {
|
|
231
247
|
const retryAfter = Math.ceil(
|