create-gardener 2.1.4 → 2.1.6

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.
@@ -14,14 +14,14 @@ const watchTarget = path.resolve('src', 'frontend');
14
14
  let debounce: ReturnType<typeof setTimeout> | null = null;
15
15
 
16
16
  fs.watch(watchTarget, { recursive: true }, (_event, filename) => {
17
- // Ignore hidden files and node_modules
18
- if (!filename || filename.startsWith('.')) return;
17
+ // Ignore hidden files and node_modules
18
+ if (!filename || filename.startsWith('.')) return;
19
19
 
20
- if (debounce) clearTimeout(debounce);
21
- debounce = setTimeout(() => {
22
- version = Date.now();
23
- console.log(`[gardener] file changed: ${filename} → version ${version}`);
24
- }, 100);
20
+ if (debounce) clearTimeout(debounce);
21
+ debounce = setTimeout(() => {
22
+ version = Date.now();
23
+ // console.log(`[gardener] file changed: ${filename} → version ${version}`);
24
+ }, 100);
25
25
  });
26
26
 
27
27
  console.log(`[gardener] watching ${watchTarget} for changes…`);
@@ -30,5 +30,5 @@ console.log(`[gardener] watching ${watchTarget} for changes…`);
30
30
  // GET /__gardener/hot-reload
31
31
  // Returns { version: <number> }
32
32
  export function hotReloadHandler(req: Request, res: Response) {
33
- res.json({ version });
33
+ res.json({ version });
34
34
  }
@@ -3,30 +3,87 @@ import fsp from "fs/promises";
3
3
  import path from "path";
4
4
  import generateWebP from "../../libs/generateWebp.js";
5
5
 
6
-
7
6
  import { fileURLToPath } from "url";
8
7
  const __filename = fileURLToPath(import.meta.url);
9
8
  const __dirname = path.dirname(__filename);
10
9
 
10
+ // Whitelist of allowed remote image domains.
11
+ // Set GARDENER_IMAGE_DOMAINS=example.com,cdn.mysite.com in your .env
12
+ function getAllowedDomains(): string[] {
13
+ const raw = process.env.GARDENER_IMAGE_DOMAINS ?? "";
14
+ return raw
15
+ .split(",")
16
+ .map((d) => d.trim().toLowerCase())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ function isAllowedUrl(rawUrl: string): { ok: true } | { ok: false; reason: string } {
21
+ let parsed: URL;
22
+ try {
23
+ parsed = new URL(rawUrl);
24
+ } catch {
25
+ return { ok: false, reason: "Malformed URL." };
26
+ }
27
+
28
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
29
+ return { ok: false, reason: "Only http/https URLs are allowed." };
30
+ }
31
+
32
+ const allowedDomains = getAllowedDomains();
33
+ if (allowedDomains.length === 0) {
34
+ return { ok: false, reason: "No allowed image domains configured. Set GARDENER_IMAGE_DOMAINS in your .env" };
35
+ }
36
+
37
+ const hostname = parsed.hostname.toLowerCase();
38
+ const isAllowed = allowedDomains.some(
39
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`)
40
+ );
41
+
42
+ if (!isAllowed) {
43
+ return { ok: false, reason: `Domain '${hostname}' is not in the allowed list.` };
44
+ }
45
+
46
+ return { ok: true };
47
+ }
48
+
49
+ async function downloadRemoteImage(
50
+ remoteUrl: string,
51
+ destPath: string
52
+ ): Promise<void> {
53
+ const response = await fetch(remoteUrl);
54
+
55
+ if (!response.ok) {
56
+ throw new Error(`Remote fetch failed: ${response.status} ${response.statusText}`);
57
+ }
58
+
59
+ const contentType = response.headers.get("content-type") ?? "";
60
+ if (!contentType.startsWith("image/")) {
61
+ throw new Error(`Remote URL did not return an image (got: ${contentType})`);
62
+ }
63
+
64
+ const buffer = await response.arrayBuffer();
65
+ await fsp.writeFile(destPath, Buffer.from(buffer));
66
+ }
11
67
 
12
68
  export async function imageOptimiser(req: Request, res: Response) {
13
69
  try {
14
70
  const { name } = req.params;
15
71
 
16
- if (typeof name !== 'string') return res.status(400).json({ success: false, message: "invalid path" });
17
- // name format: test_500x300.webp
18
- const match = name.match(/^(.+?)_(\d+)x(\d+)\.webp$/);
72
+ if (typeof name !== "string") {
73
+ return res.status(400).json({ success: false, message: "invalid path" });
74
+ }
19
75
 
76
+ // name format: baseName_{width}x{height}.webp
77
+ const match = name.match(/^(.+?)_(\d+)x(\d+)\.webp$/);
20
78
  if (!match) {
21
- return res.status(400).json({ error: "Invalid image format" });
79
+ return res.status(400).json({ error: "Invalid image format. Expected: name_{w}x{h}.webp" });
22
80
  }
23
81
 
24
82
  const [, baseName, widthStr, heightStr] = match;
83
+ const width = parseInt(widthStr!, 10);
84
+ const height = parseInt(heightStr!, 10);
25
85
 
26
- if (!widthStr || !heightStr) return;
27
- const width = parseInt(widthStr, 10);
28
- const height = parseInt(heightStr, 10);
29
-
86
+ // ── 1. Serve from cache if already converted ────────────────────────────
30
87
  const cacheDir = path.join(__dirname, "..", "..", "..", "frontend", "static", "cache");
31
88
  await fsp.mkdir(cacheDir, { recursive: true });
32
89
 
@@ -34,34 +91,66 @@ export async function imageOptimiser(req: Request, res: Response) {
34
91
 
35
92
  try {
36
93
  await fsp.access(outputPath);
37
- return res.sendFile(path.basename(outputPath), {
38
- root: path.dirname(outputPath),
39
- });
94
+ return res.sendFile(path.basename(outputPath), { root: path.dirname(outputPath) });
40
95
  } catch {
41
96
  // not cached → continue
42
97
  }
43
98
 
44
- const assetsDir = path.resolve(__dirname, '..', '..', '..', "frontend", "assets");
45
- const files = await fsp.readdir(assetsDir);
99
+ // ── 2. Locate source image in assets/ (local) ───────────────────────────
100
+ const assetsDir = path.resolve(__dirname, "..", "..", "..", "frontend", "assets");
101
+ await fsp.mkdir(assetsDir, { recursive: true });
46
102
 
47
- const sourceFile = files.find((file) => {
48
- const parsed = path.parse(file);
49
- return parsed.name === baseName;
50
- });
103
+ let inputPath: string | null = null;
51
104
 
52
- if (!sourceFile) {
53
- return res.status(404).json({ error: "Source image not found" });
105
+ const localFiles = await fsp.readdir(assetsDir);
106
+ const localMatch = localFiles.find((file) => path.parse(file).name === baseName);
107
+ if (localMatch) {
108
+ inputPath = path.join(assetsDir, localMatch);
54
109
  }
55
110
 
56
- const inputPath = path.join(assetsDir, sourceFile);
111
+ // ── 3. Check assets/remote/ if not found locally ────────────────────────
112
+ const remoteAssetsDir = path.join(assetsDir, "remote");
113
+ await fsp.mkdir(remoteAssetsDir, { recursive: true });
114
+
115
+ if (!inputPath) {
116
+ const remoteFiles = await fsp.readdir(remoteAssetsDir);
117
+ const remoteMatch = remoteFiles.find((file) => path.parse(file).name === baseName);
118
+ if (remoteMatch) {
119
+ inputPath = path.join(remoteAssetsDir, remoteMatch);
120
+ }
121
+ }
122
+
123
+ // ── 4. Download from remote URL if provided and still not found ──────────
124
+ if (!inputPath) {
125
+ const remoteUrl = req.query.url;
126
+
127
+ if (typeof remoteUrl !== "string" || !remoteUrl) {
128
+ return res.status(404).json({
129
+ error: "Source image not found. Provide ?url=<imageUrl> to use a remote source.",
130
+ });
131
+ }
132
+
133
+ const urlCheck = isAllowedUrl(remoteUrl);
134
+ if (!urlCheck.ok) {
135
+ return res.status(400).json({ error: urlCheck.reason });
136
+ }
137
+
138
+ // Infer extension from URL (fallback to .jpg)
139
+ const urlPathname = new URL(remoteUrl).pathname;
140
+ const remoteExt = path.extname(urlPathname) || ".jpg";
141
+ const destFilename = `${baseName}${remoteExt}`;
142
+ const destPath = path.join(remoteAssetsDir, destFilename);
143
+
144
+ await downloadRemoteImage(remoteUrl, destPath);
145
+ inputPath = destPath;
146
+ }
57
147
 
148
+ // ── 5. Convert & cache ───────────────────────────────────────────────────
58
149
  await generateWebP(inputPath, outputPath, width, height);
59
150
 
60
- return res.sendFile(path.basename(outputPath), {
61
- root: path.dirname(outputPath),
62
- });
151
+ return res.sendFile(path.basename(outputPath), { root: path.dirname(outputPath) });
63
152
  } catch (err) {
64
153
  console.error(err);
65
- return res.status(500).json({ error: "Image optimisation failed" });
154
+ return res.status(500).json({ error: "Image optimisation failed", detail: String(err) });
66
155
  }
67
156
  }
@@ -2,3 +2,4 @@ export * from './imageOptimiser.js';
2
2
  export * from './addPage.js';
3
3
  export * from './addComponent.js';
4
4
  export * from './saveTemplate.js';
5
+ export * from './hotReload.js'
@@ -1,7 +1,6 @@
1
1
  import type { Request, Response } from 'express';
2
2
  import { Router } from "express";
3
- import { addComponent, addPage, imageOptimiser, saveTemplate } from "../controllers/gardener/index.js";
4
- import { hotReloadHandler } from "../controllers/gardener/hotReload.js";
3
+ import { addComponent, addPage, imageOptimiser, saveTemplate, hotReloadHandler } from "../controllers/gardener/index.js";
5
4
 
6
5
  const router: Router = Router();
7
6
  export default router;
@@ -1,7 +1,9 @@
1
1
  // server.ts
2
+ import type { Request, Response, NextFunction } from 'express';
2
3
  import 'dotenv/config';
3
4
  import express from 'express';
4
5
  import frontendRoute from './routes/gardener.route.js'
6
+
5
7
  import path from "path";
6
8
 
7
9
  const app = express();
@@ -15,10 +17,26 @@ const staticFiles = path.resolve(__dirname, '..', 'frontend')
15
17
 
16
18
  app.set('views', path.join(staticFiles, 'views'));
17
19
  app.set("view engine", "ejs");
18
- app.use(express.static(staticFiles));
20
+ app.use(express.static(staticFiles,
21
+ {
22
+ maxAge: '1y', // 1 year
23
+ immutable: true
24
+ }
25
+ ));
26
+
19
27
  app.use(express.json());
20
28
  app.use(frontendRoute);
21
29
 
30
+
31
+ app.use((err: Error & { status: number }, req: Request, res: Response, next: NextFunction) => {
32
+ console.error(err.stack);
33
+
34
+ res.status(err.status || 500).json({
35
+ success: false,
36
+ message: err.message || "Internal Server Error",
37
+ });
38
+ });
39
+
22
40
  const PORT = process.env.PORT || 3000;
23
41
 
24
42
  app.listen(PORT, () => {