@gallop.software/studio 0.1.0
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/README.md +158 -0
- package/dist/StudioUI-4ST2P6R7.mjs +991 -0
- package/dist/StudioUI-4ST2P6R7.mjs.map +1 -0
- package/dist/StudioUI-BH7PWCKH.js +991 -0
- package/dist/StudioUI-BH7PWCKH.js.map +1 -0
- package/dist/handlers.d.mts +50 -0
- package/dist/handlers.d.ts +50 -0
- package/dist/handlers.js +575 -0
- package/dist/handlers.js.map +1 -0
- package/dist/handlers.mjs +575 -0
- package/dist/handlers.mjs.map +1 -0
- package/dist/index.d.mts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +103 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types-BvdwylVD.d.mts +75 -0
- package/dist/types-BvdwylVD.d.ts +75 -0
- package/package.json +68 -0
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/handlers.ts
|
|
2
|
+
var _server = require('next/server');
|
|
3
|
+
var _fs = require('fs');
|
|
4
|
+
var _path = require('path'); var _path2 = _interopRequireDefault(_path);
|
|
5
|
+
var _sharp = require('sharp'); var _sharp2 = _interopRequireDefault(_sharp);
|
|
6
|
+
var _blurhash = require('blurhash');
|
|
7
|
+
var _clients3 = require('@aws-sdk/client-s3');
|
|
8
|
+
var DEFAULT_SIZES = {
|
|
9
|
+
small: 300,
|
|
10
|
+
medium: 700,
|
|
11
|
+
large: 1400
|
|
12
|
+
};
|
|
13
|
+
async function GET(request) {
|
|
14
|
+
if (process.env.NODE_ENV !== "development") {
|
|
15
|
+
return _server.NextResponse.json({ error: "Not available in production" }, { status: 403 });
|
|
16
|
+
}
|
|
17
|
+
const pathname = request.nextUrl.pathname;
|
|
18
|
+
const route = pathname.replace(/^\/api\/studio\/?/, "");
|
|
19
|
+
if (route === "list" || route.startsWith("list")) {
|
|
20
|
+
return handleList(request);
|
|
21
|
+
}
|
|
22
|
+
if (route === "scan") {
|
|
23
|
+
return handleScan();
|
|
24
|
+
}
|
|
25
|
+
return _server.NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
26
|
+
}
|
|
27
|
+
async function POST(request) {
|
|
28
|
+
if (process.env.NODE_ENV !== "development") {
|
|
29
|
+
return _server.NextResponse.json({ error: "Not available in production" }, { status: 403 });
|
|
30
|
+
}
|
|
31
|
+
const pathname = request.nextUrl.pathname;
|
|
32
|
+
const route = pathname.replace(/^\/api\/studio\/?/, "");
|
|
33
|
+
if (route === "upload") {
|
|
34
|
+
return handleUpload(request);
|
|
35
|
+
}
|
|
36
|
+
if (route === "delete") {
|
|
37
|
+
return handleDelete(request);
|
|
38
|
+
}
|
|
39
|
+
if (route === "sync") {
|
|
40
|
+
return handleSync(request);
|
|
41
|
+
}
|
|
42
|
+
if (route === "reprocess") {
|
|
43
|
+
return handleReprocess(request);
|
|
44
|
+
}
|
|
45
|
+
return _server.NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
async function DELETE(request) {
|
|
48
|
+
if (process.env.NODE_ENV !== "development") {
|
|
49
|
+
return _server.NextResponse.json({ error: "Not available in production" }, { status: 403 });
|
|
50
|
+
}
|
|
51
|
+
return handleDelete(request);
|
|
52
|
+
}
|
|
53
|
+
async function handleList(request) {
|
|
54
|
+
const searchParams = request.nextUrl.searchParams;
|
|
55
|
+
const requestedPath = searchParams.get("path") || "public";
|
|
56
|
+
try {
|
|
57
|
+
const safePath = requestedPath.replace(/\.\./g, "");
|
|
58
|
+
const absolutePath = _path2.default.join(process.cwd(), safePath);
|
|
59
|
+
if (!absolutePath.startsWith(process.cwd())) {
|
|
60
|
+
return _server.NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
const items = [];
|
|
63
|
+
const entries = await _fs.promises.readdir(absolutePath, { withFileTypes: true });
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.name.startsWith(".")) continue;
|
|
66
|
+
const itemPath = _path2.default.join(safePath, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
items.push({
|
|
69
|
+
name: entry.name,
|
|
70
|
+
path: itemPath,
|
|
71
|
+
type: "folder"
|
|
72
|
+
});
|
|
73
|
+
} else if (isImageFile(entry.name)) {
|
|
74
|
+
const stats = await _fs.promises.stat(_path2.default.join(absolutePath, entry.name));
|
|
75
|
+
items.push({
|
|
76
|
+
name: entry.name,
|
|
77
|
+
path: itemPath,
|
|
78
|
+
type: "file",
|
|
79
|
+
size: stats.size
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return _server.NextResponse.json({ items });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error("Failed to list directory:", error);
|
|
86
|
+
return _server.NextResponse.json({ error: "Failed to list directory" }, { status: 500 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function handleScan() {
|
|
90
|
+
try {
|
|
91
|
+
const meta = await loadMeta();
|
|
92
|
+
const untrackedFiles = [];
|
|
93
|
+
const missingFiles = [];
|
|
94
|
+
const validFiles = [];
|
|
95
|
+
const imagesDir = _path2.default.join(process.cwd(), "public", "images");
|
|
96
|
+
const trackedPaths = /* @__PURE__ */ new Set();
|
|
97
|
+
for (const entry of Object.values(meta.images)) {
|
|
98
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
99
|
+
trackedPaths.add(sizeData.path);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function scanDir(dir, relativePath = "") {
|
|
103
|
+
try {
|
|
104
|
+
const entries = await _fs.promises.readdir(dir, { withFileTypes: true });
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (entry.name.startsWith(".")) continue;
|
|
107
|
+
const fullPath = _path2.default.join(dir, entry.name);
|
|
108
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
await scanDir(fullPath, relPath);
|
|
111
|
+
} else if (isImageFile(entry.name)) {
|
|
112
|
+
const publicPath = `/images/${relPath}`;
|
|
113
|
+
if (!trackedPaths.has(publicPath)) {
|
|
114
|
+
untrackedFiles.push(publicPath);
|
|
115
|
+
} else {
|
|
116
|
+
validFiles.push(publicPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await scanDir(imagesDir);
|
|
124
|
+
for (const [key, entry] of Object.entries(meta.images)) {
|
|
125
|
+
for (const [size, sizeData] of Object.entries(entry.sizes)) {
|
|
126
|
+
const filePath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
127
|
+
try {
|
|
128
|
+
await _fs.promises.access(filePath);
|
|
129
|
+
} catch (e2) {
|
|
130
|
+
if (!_optionalChain([entry, 'access', _ => _.cdn, 'optionalAccess', _2 => _2.synced])) {
|
|
131
|
+
missingFiles.push(`${key} (${size}): ${sizeData.path}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return _server.NextResponse.json({
|
|
137
|
+
totalInMeta: Object.keys(meta.images).length,
|
|
138
|
+
validFiles: validFiles.length,
|
|
139
|
+
untrackedFiles,
|
|
140
|
+
missingFiles
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.error("Failed to scan:", error);
|
|
144
|
+
return _server.NextResponse.json({ error: "Failed to scan" }, { status: 500 });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function handleUpload(request) {
|
|
148
|
+
try {
|
|
149
|
+
const formData = await request.formData();
|
|
150
|
+
const file = formData.get("file");
|
|
151
|
+
const targetPath = formData.get("path") || "public/originals";
|
|
152
|
+
if (!file) {
|
|
153
|
+
return _server.NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
154
|
+
}
|
|
155
|
+
const bytes = await file.arrayBuffer();
|
|
156
|
+
const buffer = Buffer.from(bytes);
|
|
157
|
+
const fileName = file.name;
|
|
158
|
+
const baseName = _path2.default.basename(fileName, _path2.default.extname(fileName));
|
|
159
|
+
const ext = _path2.default.extname(fileName).toLowerCase();
|
|
160
|
+
const meta = await loadMeta();
|
|
161
|
+
const imageKey = targetPath.replace(/^public\/originals\/?/, "").replace(/^public\/images\/?/, "");
|
|
162
|
+
const fullImageKey = imageKey ? `${imageKey}/${fileName}` : fileName;
|
|
163
|
+
if (meta.images[fullImageKey]) {
|
|
164
|
+
return _server.NextResponse.json(
|
|
165
|
+
{ error: `File '${fullImageKey}' already exists in meta` },
|
|
166
|
+
{ status: 409 }
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const originalsPath = _path2.default.join(process.cwd(), "public", "originals", imageKey);
|
|
170
|
+
await _fs.promises.mkdir(originalsPath, { recursive: true });
|
|
171
|
+
await _fs.promises.writeFile(_path2.default.join(originalsPath, fileName), buffer);
|
|
172
|
+
const sharpInstance = _sharp2.default.call(void 0, buffer);
|
|
173
|
+
const metadata = await sharpInstance.metadata();
|
|
174
|
+
const originalWidth = metadata.width || 0;
|
|
175
|
+
const originalHeight = metadata.height || 0;
|
|
176
|
+
const imagesPath = _path2.default.join(process.cwd(), "public", "images", imageKey);
|
|
177
|
+
await _fs.promises.mkdir(imagesPath, { recursive: true });
|
|
178
|
+
const sizes = {
|
|
179
|
+
full: { path: "", width: originalWidth, height: originalHeight },
|
|
180
|
+
large: { path: "", width: 0, height: 0 },
|
|
181
|
+
medium: { path: "", width: 0, height: 0 },
|
|
182
|
+
small: { path: "", width: 0, height: 0 }
|
|
183
|
+
};
|
|
184
|
+
const fullPath = _path2.default.join(imagesPath, fileName);
|
|
185
|
+
await _sharp2.default.call(void 0, buffer).jpeg({ quality: 85 }).toFile(fullPath);
|
|
186
|
+
sizes.full.path = `/images/${imageKey ? imageKey + "/" : ""}${fileName}`;
|
|
187
|
+
for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES)) {
|
|
188
|
+
if (originalWidth <= maxWidth) {
|
|
189
|
+
sizes[sizeName] = { ...sizes.full };
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const ratio = originalHeight / originalWidth;
|
|
193
|
+
const newHeight = Math.round(maxWidth * ratio);
|
|
194
|
+
const sizeFileName = `${baseName}-${maxWidth}${ext === ".png" ? ".png" : ".jpg"}`;
|
|
195
|
+
const sizePath = _path2.default.join(imagesPath, sizeFileName);
|
|
196
|
+
await _sharp2.default.call(void 0, buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath);
|
|
197
|
+
sizes[sizeName] = {
|
|
198
|
+
path: `/images/${imageKey ? imageKey + "/" : ""}${sizeFileName}`,
|
|
199
|
+
width: maxWidth,
|
|
200
|
+
height: newHeight
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const { data, info } = await _sharp2.default.call(void 0, buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
204
|
+
const blurhash = _blurhash.encode.call(void 0, new Uint8ClampedArray(data), info.width, info.height, 4, 4);
|
|
205
|
+
const { dominant } = await _sharp2.default.call(void 0, buffer).stats();
|
|
206
|
+
const dominantColor = `#${dominant.r.toString(16).padStart(2, "0")}${dominant.g.toString(16).padStart(2, "0")}${dominant.b.toString(16).padStart(2, "0")}`;
|
|
207
|
+
const entry = {
|
|
208
|
+
original: {
|
|
209
|
+
path: `/originals/${imageKey ? imageKey + "/" : ""}${fileName}`,
|
|
210
|
+
width: originalWidth,
|
|
211
|
+
height: originalHeight,
|
|
212
|
+
fileSize: buffer.length
|
|
213
|
+
},
|
|
214
|
+
sizes,
|
|
215
|
+
blurhash,
|
|
216
|
+
dominantColor,
|
|
217
|
+
cdn: null
|
|
218
|
+
};
|
|
219
|
+
meta.images[fullImageKey] = entry;
|
|
220
|
+
await saveMeta(meta);
|
|
221
|
+
return _server.NextResponse.json({ success: true, imageKey: fullImageKey, entry });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error("Failed to upload:", error);
|
|
224
|
+
return _server.NextResponse.json({ error: "Failed to upload file" }, { status: 500 });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function handleDelete(request) {
|
|
228
|
+
try {
|
|
229
|
+
const { paths } = await request.json();
|
|
230
|
+
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
|
231
|
+
return _server.NextResponse.json({ error: "No paths provided" }, { status: 400 });
|
|
232
|
+
}
|
|
233
|
+
const meta = await loadMeta();
|
|
234
|
+
const deleted = [];
|
|
235
|
+
const errors = [];
|
|
236
|
+
for (const itemPath of paths) {
|
|
237
|
+
try {
|
|
238
|
+
if (!itemPath.startsWith("public/")) {
|
|
239
|
+
errors.push(`Invalid path: ${itemPath}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const absolutePath = _path2.default.join(process.cwd(), itemPath);
|
|
243
|
+
const stats = await _fs.promises.stat(absolutePath);
|
|
244
|
+
if (stats.isDirectory()) {
|
|
245
|
+
await _fs.promises.rm(absolutePath, { recursive: true });
|
|
246
|
+
const prefix = itemPath.replace(/^public\/originals\/?/, "").replace(/^public\/images\/?/, "");
|
|
247
|
+
for (const key of Object.keys(meta.images)) {
|
|
248
|
+
if (key.startsWith(prefix)) {
|
|
249
|
+
delete meta.images[key];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
await _fs.promises.unlink(absolutePath);
|
|
254
|
+
const imageKey = itemPath.replace(/^public\/originals\//, "").replace(/^public\/images\//, "");
|
|
255
|
+
if (itemPath.includes("/originals/")) {
|
|
256
|
+
const entry = meta.images[imageKey];
|
|
257
|
+
if (entry) {
|
|
258
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
259
|
+
const sizePath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
260
|
+
try {
|
|
261
|
+
await _fs.promises.unlink(sizePath);
|
|
262
|
+
} catch (e3) {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
delete meta.images[imageKey];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
deleted.push(itemPath);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error(`Failed to delete ${itemPath}:`, error);
|
|
272
|
+
errors.push(itemPath);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await saveMeta(meta);
|
|
276
|
+
return _server.NextResponse.json({
|
|
277
|
+
success: true,
|
|
278
|
+
deleted,
|
|
279
|
+
errors: errors.length > 0 ? errors : void 0
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error("Failed to delete:", error);
|
|
283
|
+
return _server.NextResponse.json({ error: "Failed to delete files" }, { status: 500 });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function handleSync(request) {
|
|
287
|
+
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
|
|
288
|
+
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
|
|
289
|
+
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
|
|
290
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
|
|
291
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL;
|
|
292
|
+
if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
|
|
293
|
+
return _server.NextResponse.json(
|
|
294
|
+
{ error: "R2 not configured. Set CLOUDFLARE_R2_* environment variables." },
|
|
295
|
+
{ status: 400 }
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const { imageKeys } = await request.json();
|
|
300
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
301
|
+
return _server.NextResponse.json({ error: "No image keys provided" }, { status: 400 });
|
|
302
|
+
}
|
|
303
|
+
const meta = await loadMeta();
|
|
304
|
+
const r2 = new (0, _clients3.S3Client)({
|
|
305
|
+
region: "auto",
|
|
306
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
307
|
+
credentials: { accessKeyId, secretAccessKey }
|
|
308
|
+
});
|
|
309
|
+
const synced = [];
|
|
310
|
+
const errors = [];
|
|
311
|
+
for (const imageKey of imageKeys) {
|
|
312
|
+
const entry = meta.images[imageKey];
|
|
313
|
+
if (!entry) {
|
|
314
|
+
errors.push(`Image not found in meta: ${imageKey}`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (_optionalChain([entry, 'access', _3 => _3.cdn, 'optionalAccess', _4 => _4.synced])) {
|
|
318
|
+
synced.push(imageKey);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
323
|
+
const localPath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
324
|
+
const fileBuffer = await _fs.promises.readFile(localPath);
|
|
325
|
+
await r2.send(
|
|
326
|
+
new (0, _clients3.PutObjectCommand)({
|
|
327
|
+
Bucket: bucketName,
|
|
328
|
+
Key: sizeData.path.replace(/^\//, ""),
|
|
329
|
+
Body: fileBuffer,
|
|
330
|
+
ContentType: getContentType(sizeData.path)
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
entry.cdn = {
|
|
335
|
+
synced: true,
|
|
336
|
+
baseUrl: publicUrl,
|
|
337
|
+
syncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
338
|
+
};
|
|
339
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
340
|
+
const localPath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
341
|
+
try {
|
|
342
|
+
await _fs.promises.unlink(localPath);
|
|
343
|
+
} catch (e4) {
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
synced.push(imageKey);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(`Failed to sync ${imageKey}:`, error);
|
|
349
|
+
errors.push(imageKey);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await saveMeta(meta);
|
|
353
|
+
return _server.NextResponse.json({
|
|
354
|
+
success: true,
|
|
355
|
+
synced,
|
|
356
|
+
errors: errors.length > 0 ? errors : void 0
|
|
357
|
+
});
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.error("Failed to sync:", error);
|
|
360
|
+
return _server.NextResponse.json({ error: "Failed to sync to CDN" }, { status: 500 });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function handleReprocess(request) {
|
|
364
|
+
try {
|
|
365
|
+
const { imageKeys } = await request.json();
|
|
366
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
367
|
+
return _server.NextResponse.json({ error: "No image keys provided" }, { status: 400 });
|
|
368
|
+
}
|
|
369
|
+
const meta = await loadMeta();
|
|
370
|
+
const processed = [];
|
|
371
|
+
const errors = [];
|
|
372
|
+
for (const imageKey of imageKeys) {
|
|
373
|
+
const entry = meta.images[imageKey];
|
|
374
|
+
if (!entry) {
|
|
375
|
+
errors.push(`Image not found in meta: ${imageKey}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
let buffer;
|
|
380
|
+
const originalPath = _path2.default.join(process.cwd(), "public", entry.original.path);
|
|
381
|
+
try {
|
|
382
|
+
buffer = await _fs.promises.readFile(originalPath);
|
|
383
|
+
} catch (e5) {
|
|
384
|
+
if (_optionalChain([entry, 'access', _5 => _5.cdn, 'optionalAccess', _6 => _6.synced])) {
|
|
385
|
+
buffer = await downloadFromCdn(entry.original.path);
|
|
386
|
+
} else {
|
|
387
|
+
throw new Error("Original not found locally and not on CDN");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const updatedEntry = await processImage(buffer, entry, imageKey);
|
|
391
|
+
meta.images[imageKey] = updatedEntry;
|
|
392
|
+
if (_optionalChain([entry, 'access', _7 => _7.cdn, 'optionalAccess', _8 => _8.synced])) {
|
|
393
|
+
await uploadToCdn(updatedEntry);
|
|
394
|
+
await deleteLocalFiles(updatedEntry);
|
|
395
|
+
}
|
|
396
|
+
processed.push(imageKey);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error(`Failed to reprocess ${imageKey}:`, error);
|
|
399
|
+
errors.push(imageKey);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
await saveMeta(meta);
|
|
403
|
+
return _server.NextResponse.json({
|
|
404
|
+
success: true,
|
|
405
|
+
processed,
|
|
406
|
+
errors: errors.length > 0 ? errors : void 0
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("Failed to reprocess:", error);
|
|
410
|
+
return _server.NextResponse.json({ error: "Failed to reprocess images" }, { status: 500 });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function loadMeta() {
|
|
414
|
+
const metaPath = _path2.default.join(process.cwd(), "_data", "_meta.json");
|
|
415
|
+
try {
|
|
416
|
+
const content = await _fs.promises.readFile(metaPath, "utf-8");
|
|
417
|
+
return JSON.parse(content);
|
|
418
|
+
} catch (e6) {
|
|
419
|
+
return {
|
|
420
|
+
$schema: "https://gallop.software/schemas/studio-meta.json",
|
|
421
|
+
version: 1,
|
|
422
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
423
|
+
images: {}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function saveMeta(meta) {
|
|
428
|
+
const metaPath = _path2.default.join(process.cwd(), "_data", "_meta.json");
|
|
429
|
+
await _fs.promises.mkdir(_path2.default.join(process.cwd(), "_data"), { recursive: true });
|
|
430
|
+
meta.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
431
|
+
await _fs.promises.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
432
|
+
}
|
|
433
|
+
function isImageFile(filename) {
|
|
434
|
+
const ext = _path2.default.extname(filename).toLowerCase();
|
|
435
|
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"].includes(ext);
|
|
436
|
+
}
|
|
437
|
+
function getContentType(filePath) {
|
|
438
|
+
const ext = _path2.default.extname(filePath).toLowerCase();
|
|
439
|
+
switch (ext) {
|
|
440
|
+
case ".jpg":
|
|
441
|
+
case ".jpeg":
|
|
442
|
+
return "image/jpeg";
|
|
443
|
+
case ".png":
|
|
444
|
+
return "image/png";
|
|
445
|
+
case ".gif":
|
|
446
|
+
return "image/gif";
|
|
447
|
+
case ".webp":
|
|
448
|
+
return "image/webp";
|
|
449
|
+
case ".svg":
|
|
450
|
+
return "image/svg+xml";
|
|
451
|
+
default:
|
|
452
|
+
return "application/octet-stream";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function processImage(buffer, entry, imageKey) {
|
|
456
|
+
const sharpInstance = _sharp2.default.call(void 0, buffer);
|
|
457
|
+
const metadata = await sharpInstance.metadata();
|
|
458
|
+
const originalWidth = metadata.width || 0;
|
|
459
|
+
const originalHeight = metadata.height || 0;
|
|
460
|
+
const baseName = _path2.default.basename(imageKey, _path2.default.extname(imageKey));
|
|
461
|
+
const ext = _path2.default.extname(imageKey).toLowerCase();
|
|
462
|
+
const imageDir = _path2.default.dirname(imageKey);
|
|
463
|
+
const imagesPath = _path2.default.join(process.cwd(), "public", "images", imageDir === "." ? "" : imageDir);
|
|
464
|
+
await _fs.promises.mkdir(imagesPath, { recursive: true });
|
|
465
|
+
const sizes = {
|
|
466
|
+
full: { path: "", width: originalWidth, height: originalHeight },
|
|
467
|
+
large: { path: "", width: 0, height: 0 },
|
|
468
|
+
medium: { path: "", width: 0, height: 0 },
|
|
469
|
+
small: { path: "", width: 0, height: 0 }
|
|
470
|
+
};
|
|
471
|
+
const fullFileName = imageDir === "." ? `${baseName}${ext}` : `${imageDir}/${baseName}${ext}`;
|
|
472
|
+
const fullPath = _path2.default.join(process.cwd(), "public", "images", fullFileName);
|
|
473
|
+
await _sharp2.default.call(void 0, buffer).jpeg({ quality: 85 }).toFile(fullPath);
|
|
474
|
+
sizes.full.path = `/images/${fullFileName}`;
|
|
475
|
+
for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES)) {
|
|
476
|
+
if (originalWidth <= maxWidth) {
|
|
477
|
+
sizes[sizeName] = { ...sizes.full };
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const ratio = originalHeight / originalWidth;
|
|
481
|
+
const newHeight = Math.round(maxWidth * ratio);
|
|
482
|
+
const sizeFileName = `${baseName}-${maxWidth}${ext === ".png" ? ".png" : ".jpg"}`;
|
|
483
|
+
const sizeFilePath = imageDir === "." ? sizeFileName : `${imageDir}/${sizeFileName}`;
|
|
484
|
+
const sizePath = _path2.default.join(process.cwd(), "public", "images", sizeFilePath);
|
|
485
|
+
await _sharp2.default.call(void 0, buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath);
|
|
486
|
+
sizes[sizeName] = {
|
|
487
|
+
path: `/images/${sizeFilePath}`,
|
|
488
|
+
width: maxWidth,
|
|
489
|
+
height: newHeight
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const { data, info } = await _sharp2.default.call(void 0, buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
493
|
+
const blurhash = _blurhash.encode.call(void 0, new Uint8ClampedArray(data), info.width, info.height, 4, 4);
|
|
494
|
+
const { dominant } = await _sharp2.default.call(void 0, buffer).stats();
|
|
495
|
+
const dominantColor = `#${dominant.r.toString(16).padStart(2, "0")}${dominant.g.toString(16).padStart(2, "0")}${dominant.b.toString(16).padStart(2, "0")}`;
|
|
496
|
+
return {
|
|
497
|
+
...entry,
|
|
498
|
+
original: {
|
|
499
|
+
...entry.original,
|
|
500
|
+
width: originalWidth,
|
|
501
|
+
height: originalHeight,
|
|
502
|
+
fileSize: buffer.length
|
|
503
|
+
},
|
|
504
|
+
sizes,
|
|
505
|
+
blurhash,
|
|
506
|
+
dominantColor
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function downloadFromCdn(originalPath) {
|
|
510
|
+
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
|
|
511
|
+
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
|
|
512
|
+
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
|
|
513
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
|
|
514
|
+
if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {
|
|
515
|
+
throw new Error("R2 not configured");
|
|
516
|
+
}
|
|
517
|
+
const r2 = new (0, _clients3.S3Client)({
|
|
518
|
+
region: "auto",
|
|
519
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
520
|
+
credentials: { accessKeyId, secretAccessKey }
|
|
521
|
+
});
|
|
522
|
+
const response = await r2.send(
|
|
523
|
+
new (0, _clients3.GetObjectCommand)({
|
|
524
|
+
Bucket: bucketName,
|
|
525
|
+
Key: originalPath.replace(/^\//, "")
|
|
526
|
+
})
|
|
527
|
+
);
|
|
528
|
+
const stream = response.Body;
|
|
529
|
+
const chunks = [];
|
|
530
|
+
for await (const chunk of stream) {
|
|
531
|
+
chunks.push(Buffer.from(chunk));
|
|
532
|
+
}
|
|
533
|
+
return Buffer.concat(chunks);
|
|
534
|
+
}
|
|
535
|
+
async function uploadToCdn(entry) {
|
|
536
|
+
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
|
|
537
|
+
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
|
|
538
|
+
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
|
|
539
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
|
|
540
|
+
if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {
|
|
541
|
+
throw new Error("R2 not configured");
|
|
542
|
+
}
|
|
543
|
+
const r2 = new (0, _clients3.S3Client)({
|
|
544
|
+
region: "auto",
|
|
545
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
546
|
+
credentials: { accessKeyId, secretAccessKey }
|
|
547
|
+
});
|
|
548
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
549
|
+
const localPath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
550
|
+
const fileBuffer = await _fs.promises.readFile(localPath);
|
|
551
|
+
await r2.send(
|
|
552
|
+
new (0, _clients3.PutObjectCommand)({
|
|
553
|
+
Bucket: bucketName,
|
|
554
|
+
Key: sizeData.path.replace(/^\//, ""),
|
|
555
|
+
Body: fileBuffer,
|
|
556
|
+
ContentType: getContentType(sizeData.path)
|
|
557
|
+
})
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function deleteLocalFiles(entry) {
|
|
562
|
+
for (const sizeData of Object.values(entry.sizes)) {
|
|
563
|
+
const localPath = _path2.default.join(process.cwd(), "public", sizeData.path);
|
|
564
|
+
try {
|
|
565
|
+
await _fs.promises.unlink(localPath);
|
|
566
|
+
} catch (e7) {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
exports.DELETE = DELETE; exports.GET = GET; exports.POST = POST;
|
|
575
|
+
//# sourceMappingURL=handlers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/handlers.js","../src/handlers.ts"],"names":[],"mappings":"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","file":"/Users/chrisb/Sites/studio/dist/handlers.js","sourcesContent":[null,"import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { encode } from 'blurhash'\nimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'\nimport type { StudioMeta, ImageEntry, ImageSize, FileItem } from './types'\n\n// Default thumbnail sizes\nconst DEFAULT_SIZES = {\n small: 300,\n medium: 700,\n large: 1400,\n}\n\n/**\n * Unified GET handler for all Studio API routes\n */\nexport async function GET(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/list\n if (route === 'list' || route.startsWith('list')) {\n return handleList(request)\n }\n\n // Route: /api/studio/scan\n if (route === 'scan') {\n return handleScan()\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified POST handler for all Studio API routes\n */\nexport async function POST(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/upload\n if (route === 'upload') {\n return handleUpload(request)\n }\n\n // Route: /api/studio/delete\n if (route === 'delete') {\n return handleDelete(request)\n }\n\n // Route: /api/studio/sync\n if (route === 'sync') {\n return handleSync(request)\n }\n\n // Route: /api/studio/reprocess\n if (route === 'reprocess') {\n return handleReprocess(request)\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified DELETE handler\n */\nexport async function DELETE(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n return handleDelete(request)\n}\n\n// ============================================================================\n// Handler implementations\n// ============================================================================\n\nasync function handleList(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const requestedPath = searchParams.get('path') || 'public'\n\n try {\n const safePath = requestedPath.replace(/\\.\\./g, '')\n const absolutePath = path.join(process.cwd(), safePath)\n\n if (!absolutePath.startsWith(process.cwd())) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n const items: FileItem[] = []\n const entries = await fs.readdir(absolutePath, { withFileTypes: true })\n\n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const itemPath = path.join(safePath, entry.name)\n\n if (entry.isDirectory()) {\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'folder',\n })\n } else if (isImageFile(entry.name)) {\n const stats = await fs.stat(path.join(absolutePath, entry.name))\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'file',\n size: stats.size,\n })\n }\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to list directory:', error)\n return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })\n }\n}\n\nasync function handleScan() {\n try {\n const meta = await loadMeta()\n\n const untrackedFiles: string[] = []\n const missingFiles: string[] = []\n const validFiles: string[] = []\n\n const imagesDir = path.join(process.cwd(), 'public', 'images')\n const trackedPaths = new Set<string>()\n\n for (const entry of Object.values(meta.images)) {\n for (const sizeData of Object.values(entry.sizes)) {\n trackedPaths.add(sizeData.path)\n }\n }\n\n async function scanDir(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) {\n await scanDir(fullPath, relPath)\n } else if (isImageFile(entry.name)) {\n const publicPath = `/images/${relPath}`\n if (!trackedPaths.has(publicPath)) {\n untrackedFiles.push(publicPath)\n } else {\n validFiles.push(publicPath)\n }\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n await scanDir(imagesDir)\n\n for (const [key, entry] of Object.entries(meta.images)) {\n for (const [size, sizeData] of Object.entries(entry.sizes)) {\n const filePath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.access(filePath)\n } catch {\n if (!entry.cdn?.synced) {\n missingFiles.push(`${key} (${size}): ${sizeData.path}`)\n }\n }\n }\n }\n\n return NextResponse.json({\n totalInMeta: Object.keys(meta.images).length,\n validFiles: validFiles.length,\n untrackedFiles,\n missingFiles,\n })\n } catch (error) {\n console.error('Failed to scan:', error)\n return NextResponse.json({ error: 'Failed to scan' }, { status: 500 })\n }\n}\n\nasync function handleUpload(request: NextRequest) {\n try {\n const formData = await request.formData()\n const file = formData.get('file') as File | null\n const targetPath = formData.get('path') as string || 'public/originals'\n\n if (!file) {\n return NextResponse.json({ error: 'No file provided' }, { status: 400 })\n }\n\n const bytes = await file.arrayBuffer()\n const buffer = Buffer.from(bytes)\n\n const fileName = file.name\n const baseName = path.basename(fileName, path.extname(fileName))\n const ext = path.extname(fileName).toLowerCase()\n\n const meta = await loadMeta()\n\n const imageKey = targetPath\n .replace(/^public\\/originals\\/?/, '')\n .replace(/^public\\/images\\/?/, '')\n const fullImageKey = imageKey ? `${imageKey}/${fileName}` : fileName\n\n if (meta.images[fullImageKey]) {\n return NextResponse.json(\n { error: `File '${fullImageKey}' already exists in meta` },\n { status: 409 }\n )\n }\n\n // Save original\n const originalsPath = path.join(process.cwd(), 'public', 'originals', imageKey)\n await fs.mkdir(originalsPath, { recursive: true })\n await fs.writeFile(path.join(originalsPath, fileName), buffer)\n\n // Get dimensions\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n // Generate thumbnails\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageKey)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: originalWidth, height: originalHeight },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n // Full size\n const fullPath = path.join(imagesPath, fileName)\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n sizes.full.path = `/images/${imageKey ? imageKey + '/' : ''}${fileName}`\n\n // Generate each size\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${ext === '.png' ? '.png' : '.jpg'}`\n const sizePath = path.join(imagesPath, sizeFileName)\n\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n\n sizes[sizeName] = {\n path: `/images/${imageKey ? imageKey + '/' : ''}${sizeFileName}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n // Blurhash\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n // Dominant color\n const { dominant } = await sharp(buffer).stats()\n const dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n\n const entry: ImageEntry = {\n original: {\n path: `/originals/${imageKey ? imageKey + '/' : ''}${fileName}`,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n cdn: null,\n }\n\n meta.images[fullImageKey] = entry\n await saveMeta(meta)\n\n return NextResponse.json({ success: true, imageKey: fullImageKey, entry })\n } catch (error) {\n console.error('Failed to upload:', error)\n return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })\n }\n}\n\nasync function handleDelete(request: NextRequest) {\n try {\n const { paths } = await request.json() as { paths: string[] }\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'No paths provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const deleted: string[] = []\n const errors: string[] = []\n\n for (const itemPath of paths) {\n try {\n if (!itemPath.startsWith('public/')) {\n errors.push(`Invalid path: ${itemPath}`)\n continue\n }\n\n const absolutePath = path.join(process.cwd(), itemPath)\n const stats = await fs.stat(absolutePath)\n\n if (stats.isDirectory()) {\n await fs.rm(absolutePath, { recursive: true })\n \n const prefix = itemPath\n .replace(/^public\\/originals\\/?/, '')\n .replace(/^public\\/images\\/?/, '')\n \n for (const key of Object.keys(meta.images)) {\n if (key.startsWith(prefix)) {\n delete meta.images[key]\n }\n }\n } else {\n await fs.unlink(absolutePath)\n\n const imageKey = itemPath\n .replace(/^public\\/originals\\//, '')\n .replace(/^public\\/images\\//, '')\n\n if (itemPath.includes('/originals/')) {\n const entry = meta.images[imageKey]\n if (entry) {\n for (const sizeData of Object.values(entry.sizes)) {\n const sizePath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(sizePath) } catch { /* ignore */ }\n }\n delete meta.images[imageKey]\n }\n }\n }\n\n deleted.push(itemPath)\n } catch (error) {\n console.error(`Failed to delete ${itemPath}:`, error)\n errors.push(itemPath)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n deleted,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to delete:', error)\n return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })\n }\n}\n\nasync function handleSync(request: NextRequest) {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {\n return NextResponse.json(\n { error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },\n { status: 400 }\n )\n }\n\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const synced: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n if (entry.cdn?.synced) {\n synced.push(imageKey)\n continue\n }\n\n try {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n\n entry.cdn = {\n synced: true,\n baseUrl: publicUrl,\n syncedAt: new Date().toISOString(),\n }\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(localPath) } catch { /* ignore */ }\n }\n\n synced.push(imageKey)\n } catch (error) {\n console.error(`Failed to sync ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n synced,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to sync:', error)\n return NextResponse.json({ error: 'Failed to sync to CDN' }, { status: 500 })\n }\n}\n\nasync function handleReprocess(request: NextRequest) {\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n try {\n let buffer: Buffer\n\n const originalPath = path.join(process.cwd(), 'public', entry.original.path)\n try {\n buffer = await fs.readFile(originalPath)\n } catch {\n if (entry.cdn?.synced) {\n buffer = await downloadFromCdn(entry.original.path)\n } else {\n throw new Error('Original not found locally and not on CDN')\n }\n }\n\n const updatedEntry = await processImage(buffer, entry, imageKey)\n meta.images[imageKey] = updatedEntry\n\n if (entry.cdn?.synced) {\n await uploadToCdn(updatedEntry)\n await deleteLocalFiles(updatedEntry)\n }\n\n processed.push(imageKey)\n } catch (error) {\n console.error(`Failed to reprocess ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n processed,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to reprocess:', error)\n return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })\n }\n}\n\n// ============================================================================\n// Helper functions\n// ============================================================================\n\nasync function loadMeta(): Promise<StudioMeta> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n try {\n const content = await fs.readFile(metaPath, 'utf-8')\n return JSON.parse(content)\n } catch {\n return {\n $schema: 'https://gallop.software/schemas/studio-meta.json',\n version: 1,\n generatedAt: new Date().toISOString(),\n images: {},\n }\n }\n}\n\nasync function saveMeta(meta: StudioMeta): Promise<void> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n await fs.mkdir(path.join(process.cwd(), '_data'), { recursive: true })\n meta.generatedAt = new Date().toISOString()\n await fs.writeFile(metaPath, JSON.stringify(meta, null, 2))\n}\n\nfunction isImageFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)\n}\n\nfunction getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase()\n switch (ext) {\n case '.jpg':\n case '.jpeg':\n return 'image/jpeg'\n case '.png':\n return 'image/png'\n case '.gif':\n return 'image/gif'\n case '.webp':\n return 'image/webp'\n case '.svg':\n return 'image/svg+xml'\n default:\n return 'application/octet-stream'\n }\n}\n\nasync function processImage(\n buffer: Buffer,\n entry: ImageEntry,\n imageKey: string\n): Promise<ImageEntry> {\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n const baseName = path.basename(imageKey, path.extname(imageKey))\n const ext = path.extname(imageKey).toLowerCase()\n const imageDir = path.dirname(imageKey)\n\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: originalWidth, height: originalHeight },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n const fullFileName = imageDir === '.' ? `${baseName}${ext}` : `${imageDir}/${baseName}${ext}`\n const fullPath = path.join(process.cwd(), 'public', 'images', fullFileName)\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n sizes.full.path = `/images/${fullFileName}`\n\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${ext === '.png' ? '.png' : '.jpg'}`\n const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`\n const sizePath = path.join(process.cwd(), 'public', 'images', sizeFilePath)\n\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n\n sizes[sizeName] = {\n path: `/images/${sizeFilePath}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n const { dominant } = await sharp(buffer).stats()\n const dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n\n return {\n ...entry,\n original: {\n ...entry.original,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n }\n}\n\nasync function downloadFromCdn(originalPath: string): Promise<Buffer> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const response = await r2.send(\n new GetObjectCommand({\n Bucket: bucketName,\n Key: originalPath.replace(/^\\//, ''),\n })\n )\n\n const stream = response.Body as NodeJS.ReadableStream\n const chunks: Buffer[] = []\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk))\n }\n return Buffer.concat(chunks)\n}\n\nasync function uploadToCdn(entry: ImageEntry): Promise<void> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n}\n\nasync function deleteLocalFiles(entry: ImageEntry): Promise<void> {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.unlink(localPath)\n } catch {\n // File might not exist\n }\n }\n}\n"]}
|