@gallop.software/studio 1.5.10 → 2.0.1
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/app/api/studio/[...path]/route.ts +1 -0
- package/app/layout.tsx +23 -0
- package/app/page.tsx +90 -0
- package/bin/studio.mjs +110 -0
- package/dist/handlers/index.js +77 -55
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +128 -106
- package/dist/handlers/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -10
- package/dist/index.d.ts +14 -10
- package/dist/index.js +2 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -179
- package/dist/index.mjs.map +1 -1
- package/next.config.mjs +22 -0
- package/package.json +18 -10
- package/src/components/AddNewModal.tsx +402 -0
- package/src/components/ErrorModal.tsx +89 -0
- package/src/components/R2SetupModal.tsx +400 -0
- package/src/components/StudioBreadcrumb.tsx +115 -0
- package/src/components/StudioButton.tsx +200 -0
- package/src/components/StudioContext.tsx +219 -0
- package/src/components/StudioDetailView.tsx +714 -0
- package/src/components/StudioFileGrid.tsx +704 -0
- package/src/components/StudioFileList.tsx +743 -0
- package/src/components/StudioFolderPicker.tsx +342 -0
- package/src/components/StudioModal.tsx +473 -0
- package/src/components/StudioPreview.tsx +399 -0
- package/src/components/StudioSettings.tsx +536 -0
- package/src/components/StudioToolbar.tsx +1448 -0
- package/src/components/StudioUI.tsx +731 -0
- package/src/components/styles/common.ts +236 -0
- package/src/components/tokens.ts +78 -0
- package/src/components/useStudioActions.tsx +497 -0
- package/src/config/index.ts +7 -0
- package/src/config/workspace.ts +52 -0
- package/src/handlers/favicon.ts +152 -0
- package/src/handlers/files.ts +784 -0
- package/src/handlers/images.ts +949 -0
- package/src/handlers/import.ts +190 -0
- package/src/handlers/index.ts +168 -0
- package/src/handlers/list.ts +627 -0
- package/src/handlers/scan.ts +311 -0
- package/src/handlers/utils/cdn.ts +234 -0
- package/src/handlers/utils/files.ts +64 -0
- package/src/handlers/utils/index.ts +4 -0
- package/src/handlers/utils/meta.ts +102 -0
- package/src/handlers/utils/thumbnails.ts +98 -0
- package/src/hooks/useFileList.ts +143 -0
- package/src/index.tsx +36 -0
- package/src/lib/api.ts +176 -0
- package/src/types.ts +119 -0
- package/dist/StudioUI-GJK45R3T.js +0 -6500
- package/dist/StudioUI-GJK45R3T.js.map +0 -1
- package/dist/StudioUI-QZ54STXE.mjs +0 -6500
- package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
- package/dist/chunk-N6JYTJCB.js +0 -68
- package/dist/chunk-N6JYTJCB.js.map +0 -1
- package/dist/chunk-RHI3UROE.mjs +0 -68
- package/dist/chunk-RHI3UROE.mjs.map +0 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { promises as fs } from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { encode } from 'blurhash'
|
|
6
|
+
import { loadMeta, saveMeta, isMediaFile, isImageFile, getFileEntries } from './utils'
|
|
7
|
+
import { getAllThumbnailPaths, isProcessed } from '../types'
|
|
8
|
+
import { getPublicPath } from '../config'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Streaming scan handler - scans filesystem for new files not in meta
|
|
12
|
+
* For images, reads dimensions (w/h)
|
|
13
|
+
* Handles collisions by renaming files with -1, -2, etc.
|
|
14
|
+
* Also detects orphaned files in the images folder
|
|
15
|
+
*/
|
|
16
|
+
export async function handleScanStream() {
|
|
17
|
+
const encoder = new TextEncoder()
|
|
18
|
+
|
|
19
|
+
const stream = new ReadableStream({
|
|
20
|
+
async start(controller) {
|
|
21
|
+
const sendEvent = (data: object) => {
|
|
22
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const meta = await loadMeta()
|
|
27
|
+
const existingCount = Object.keys(meta).filter(k => !k.startsWith('_')).length
|
|
28
|
+
const existingKeys = new Set(Object.keys(meta))
|
|
29
|
+
const added: string[] = []
|
|
30
|
+
const renamed: Array<{ from: string; to: string }> = []
|
|
31
|
+
const errors: string[] = []
|
|
32
|
+
const orphanedFiles: string[] = []
|
|
33
|
+
|
|
34
|
+
// Collect all files first
|
|
35
|
+
const allFiles: Array<{ relativePath: string; fullPath: string }> = []
|
|
36
|
+
|
|
37
|
+
async function scanDir(dir: string, relativePath: string = ''): Promise<void> {
|
|
38
|
+
try {
|
|
39
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.name.startsWith('.')) continue
|
|
43
|
+
|
|
44
|
+
const fullPath = path.join(dir, entry.name)
|
|
45
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name
|
|
46
|
+
|
|
47
|
+
// Skip the images folder (generated thumbnails)
|
|
48
|
+
if (relPath === 'images' || relPath.startsWith('images/')) continue
|
|
49
|
+
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
await scanDir(fullPath, relPath)
|
|
52
|
+
} else if (isMediaFile(entry.name)) {
|
|
53
|
+
allFiles.push({ relativePath: relPath, fullPath })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Directory might not exist
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const publicDir = getPublicPath()
|
|
62
|
+
await scanDir(publicDir)
|
|
63
|
+
|
|
64
|
+
const total = allFiles.length
|
|
65
|
+
sendEvent({ type: 'start', total })
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
68
|
+
let { relativePath, fullPath } = allFiles[i]
|
|
69
|
+
let imageKey = '/' + relativePath
|
|
70
|
+
|
|
71
|
+
sendEvent({
|
|
72
|
+
type: 'progress',
|
|
73
|
+
current: i + 1,
|
|
74
|
+
total,
|
|
75
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
76
|
+
currentFile: relativePath
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Check if already in meta
|
|
80
|
+
if (existingKeys.has(imageKey)) {
|
|
81
|
+
// File already tracked - skip
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check for collision (path exists in meta but file is new)
|
|
86
|
+
if (meta[imageKey]) {
|
|
87
|
+
// Need to rename this file to avoid collision
|
|
88
|
+
const ext = path.extname(relativePath)
|
|
89
|
+
const baseName = relativePath.slice(0, -ext.length)
|
|
90
|
+
let counter = 1
|
|
91
|
+
let newKey = `/${baseName}-${counter}${ext}`
|
|
92
|
+
|
|
93
|
+
while (meta[newKey]) {
|
|
94
|
+
counter++
|
|
95
|
+
newKey = `/${baseName}-${counter}${ext}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rename the physical file
|
|
99
|
+
const newRelativePath = `${baseName}-${counter}${ext}`
|
|
100
|
+
const newFullPath = getPublicPath(newRelativePath)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await fs.rename(fullPath, newFullPath)
|
|
104
|
+
renamed.push({ from: relativePath, to: newRelativePath })
|
|
105
|
+
relativePath = newRelativePath
|
|
106
|
+
fullPath = newFullPath
|
|
107
|
+
imageKey = newKey
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(`Failed to rename ${relativePath}:`, err)
|
|
110
|
+
errors.push(`Failed to rename ${relativePath}`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const isImage = isImageFile(relativePath)
|
|
117
|
+
|
|
118
|
+
if (isImage) {
|
|
119
|
+
// Read dimensions and generate blurhash for images
|
|
120
|
+
const ext = path.extname(relativePath).toLowerCase()
|
|
121
|
+
|
|
122
|
+
if (ext === '.svg') {
|
|
123
|
+
// SVGs don't have pixel dimensions in the same way
|
|
124
|
+
meta[imageKey] = { o: { w: 0, h: 0 }, b: '' }
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
const buffer = await fs.readFile(fullPath)
|
|
128
|
+
const metadata = await sharp(buffer).metadata()
|
|
129
|
+
|
|
130
|
+
// Generate blurhash
|
|
131
|
+
const { data, info } = await sharp(buffer)
|
|
132
|
+
.resize(32, 32, { fit: 'inside' })
|
|
133
|
+
.ensureAlpha()
|
|
134
|
+
.raw()
|
|
135
|
+
.toBuffer({ resolveWithObject: true })
|
|
136
|
+
|
|
137
|
+
const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)
|
|
138
|
+
|
|
139
|
+
meta[imageKey] = {
|
|
140
|
+
o: { w: metadata.width || 0, h: metadata.height || 0 },
|
|
141
|
+
b: blurhash,
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Couldn't read dimensions
|
|
145
|
+
meta[imageKey] = { o: { w: 0, h: 0 } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// Non-image files - just add empty entry
|
|
150
|
+
meta[imageKey] = {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
existingKeys.add(imageKey)
|
|
154
|
+
added.push(imageKey)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`Failed to process ${relativePath}:`, error)
|
|
157
|
+
errors.push(relativePath)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check for orphaned files in the images folder
|
|
162
|
+
sendEvent({ type: 'cleanup', message: 'Checking for orphaned thumbnails...' })
|
|
163
|
+
|
|
164
|
+
// Build set of expected thumbnail paths from meta entries
|
|
165
|
+
const expectedThumbnails = new Set<string>()
|
|
166
|
+
const fileEntries = getFileEntries(meta)
|
|
167
|
+
for (const [imageKey, entry] of fileEntries) {
|
|
168
|
+
// Only track local thumbnails (not pushed to CDN)
|
|
169
|
+
if (entry.c === undefined && isProcessed(entry)) {
|
|
170
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
171
|
+
expectedThumbnails.add(thumbPath)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Scan the images folder for orphaned files
|
|
177
|
+
async function findOrphans(dir: string, relativePath: string = ''): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
180
|
+
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (entry.name.startsWith('.')) continue
|
|
183
|
+
|
|
184
|
+
const fullPath = path.join(dir, entry.name)
|
|
185
|
+
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name
|
|
186
|
+
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
await findOrphans(fullPath, relPath)
|
|
189
|
+
} else if (isImageFile(entry.name)) {
|
|
190
|
+
const publicPath = `/images/${relPath}`
|
|
191
|
+
if (!expectedThumbnails.has(publicPath)) {
|
|
192
|
+
orphanedFiles.push(publicPath)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Directory might not exist
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const imagesDir = getPublicPath('images')
|
|
202
|
+
try {
|
|
203
|
+
await findOrphans(imagesDir)
|
|
204
|
+
} catch {
|
|
205
|
+
// images dir might not exist
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await saveMeta(meta)
|
|
209
|
+
|
|
210
|
+
sendEvent({
|
|
211
|
+
type: 'complete',
|
|
212
|
+
existingCount,
|
|
213
|
+
added: added.length,
|
|
214
|
+
renamed: renamed.length,
|
|
215
|
+
errors: errors.length,
|
|
216
|
+
renamedFiles: renamed,
|
|
217
|
+
orphanedFiles: orphanedFiles.length > 0 ? orphanedFiles : undefined,
|
|
218
|
+
})
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error('Scan failed:', error)
|
|
221
|
+
sendEvent({ type: 'error', message: 'Scan failed' })
|
|
222
|
+
} finally {
|
|
223
|
+
controller.close()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return new Response(stream, {
|
|
229
|
+
headers: {
|
|
230
|
+
'Content-Type': 'text/event-stream',
|
|
231
|
+
'Cache-Control': 'no-cache',
|
|
232
|
+
'Connection': 'keep-alive',
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Delete orphaned files from the images folder
|
|
239
|
+
*/
|
|
240
|
+
export async function handleDeleteOrphans(request: NextRequest) {
|
|
241
|
+
try {
|
|
242
|
+
const { paths } = await request.json() as { paths: string[] }
|
|
243
|
+
|
|
244
|
+
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
|
245
|
+
return NextResponse.json({ error: 'No paths provided' }, { status: 400 })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const deleted: string[] = []
|
|
249
|
+
const errors: string[] = []
|
|
250
|
+
|
|
251
|
+
for (const orphanPath of paths) {
|
|
252
|
+
// Ensure the path is within the images folder for safety
|
|
253
|
+
if (!orphanPath.startsWith('/images/')) {
|
|
254
|
+
errors.push(`Invalid path: ${orphanPath}`)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fullPath = getPublicPath(orphanPath)
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await fs.unlink(fullPath)
|
|
262
|
+
deleted.push(orphanPath)
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error(`Failed to delete ${orphanPath}:`, err)
|
|
265
|
+
errors.push(orphanPath)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Clean up empty directories
|
|
270
|
+
const imagesDir = getPublicPath('images')
|
|
271
|
+
|
|
272
|
+
async function removeEmptyDirs(dir: string): Promise<boolean> {
|
|
273
|
+
try {
|
|
274
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
275
|
+
let isEmpty = true
|
|
276
|
+
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
if (entry.isDirectory()) {
|
|
279
|
+
const subDirEmpty = await removeEmptyDirs(path.join(dir, entry.name))
|
|
280
|
+
if (!subDirEmpty) isEmpty = false
|
|
281
|
+
} else {
|
|
282
|
+
isEmpty = false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (isEmpty && dir !== imagesDir) {
|
|
287
|
+
await fs.rmdir(dir)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return isEmpty
|
|
291
|
+
} catch {
|
|
292
|
+
return true
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
await removeEmptyDirs(imagesDir)
|
|
298
|
+
} catch {
|
|
299
|
+
// images dir might not exist
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return NextResponse.json({
|
|
303
|
+
success: true,
|
|
304
|
+
deleted: deleted.length,
|
|
305
|
+
errors: errors.length,
|
|
306
|
+
})
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error('Failed to delete orphans:', error)
|
|
309
|
+
return NextResponse.json({ error: 'Failed to delete orphaned files' }, { status: 500 })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
|
|
3
|
+
import { getAllThumbnailPaths } from '../../types'
|
|
4
|
+
import { getContentType } from './files'
|
|
5
|
+
import { getPublicPath } from '../../config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Purge URLs from Cloudflare cache
|
|
9
|
+
* Requires CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN environment variables
|
|
10
|
+
*/
|
|
11
|
+
export async function purgeCloudflareCache(urls: string[]): Promise<void> {
|
|
12
|
+
const zoneId = process.env.CLOUDFLARE_ZONE_ID
|
|
13
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN
|
|
14
|
+
|
|
15
|
+
if (!zoneId || !apiToken || urls.length === 0) {
|
|
16
|
+
return // Cache purge not configured or no URLs to purge
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(
|
|
21
|
+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
|
22
|
+
{
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Authorization': `Bearer ${apiToken}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ files: urls }),
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
console.error('Cache purge failed:', await response.text())
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('Cache purge error:', error)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getR2Client() {
|
|
41
|
+
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID
|
|
42
|
+
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID
|
|
43
|
+
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY
|
|
44
|
+
|
|
45
|
+
if (!accountId || !accessKeyId || !secretAccessKey) {
|
|
46
|
+
throw new Error('R2 not configured')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new S3Client({
|
|
50
|
+
region: 'auto',
|
|
51
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
52
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function downloadFromCdn(originalPath: string): Promise<Buffer> {
|
|
57
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
58
|
+
if (!bucketName) throw new Error('R2 bucket not configured')
|
|
59
|
+
|
|
60
|
+
const r2 = getR2Client()
|
|
61
|
+
const maxRetries = 3
|
|
62
|
+
let lastError: Error | undefined
|
|
63
|
+
|
|
64
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
const response = await r2.send(
|
|
67
|
+
new GetObjectCommand({
|
|
68
|
+
Bucket: bucketName,
|
|
69
|
+
Key: originalPath.replace(/^\//, ''),
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const stream = response.Body as NodeJS.ReadableStream
|
|
74
|
+
const chunks: Buffer[] = []
|
|
75
|
+
for await (const chunk of stream) {
|
|
76
|
+
chunks.push(Buffer.from(chunk))
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(chunks)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
lastError = error as Error
|
|
81
|
+
// Wait before retry (exponential backoff: 500ms, 1s)
|
|
82
|
+
if (attempt < maxRetries - 1) {
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw lastError || new Error(`Failed to download ${originalPath} after ${maxRetries} attempts`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function uploadToCdn(imageKey: string): Promise<void> {
|
|
92
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
93
|
+
if (!bucketName) throw new Error('R2 bucket not configured')
|
|
94
|
+
|
|
95
|
+
const r2 = getR2Client()
|
|
96
|
+
|
|
97
|
+
// Upload all thumbnail sizes derived from imageKey
|
|
98
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
99
|
+
const localPath = getPublicPath(thumbPath)
|
|
100
|
+
try {
|
|
101
|
+
const fileBuffer = await fs.readFile(localPath)
|
|
102
|
+
await r2.send(
|
|
103
|
+
new PutObjectCommand({
|
|
104
|
+
Bucket: bucketName,
|
|
105
|
+
Key: thumbPath.replace(/^\//, ''),
|
|
106
|
+
Body: fileBuffer,
|
|
107
|
+
ContentType: getContentType(thumbPath),
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
} catch {
|
|
111
|
+
// File might not exist (e.g., if image is smaller than thumbnail size)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function deleteLocalThumbnails(imageKey: string): Promise<void> {
|
|
117
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
118
|
+
const localPath = getPublicPath(thumbPath)
|
|
119
|
+
try {
|
|
120
|
+
await fs.unlink(localPath)
|
|
121
|
+
} catch {
|
|
122
|
+
// File might not exist
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Download image from a remote URL (not R2)
|
|
129
|
+
*/
|
|
130
|
+
export async function downloadFromRemoteUrl(url: string): Promise<Buffer> {
|
|
131
|
+
const maxRetries = 3
|
|
132
|
+
let lastError: Error | undefined
|
|
133
|
+
|
|
134
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(url)
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(`Failed to download from ${url}: ${response.status}`)
|
|
139
|
+
}
|
|
140
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
141
|
+
return Buffer.from(arrayBuffer)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
lastError = error as Error
|
|
144
|
+
// Wait before retry (exponential backoff: 500ms, 1s)
|
|
145
|
+
if (attempt < maxRetries - 1) {
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw lastError || new Error(`Failed to download from ${url} after ${maxRetries} attempts`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Upload original image to R2 CDN
|
|
156
|
+
*/
|
|
157
|
+
export async function uploadOriginalToCdn(imageKey: string): Promise<void> {
|
|
158
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
159
|
+
if (!bucketName) throw new Error('R2 bucket not configured')
|
|
160
|
+
|
|
161
|
+
const r2 = getR2Client()
|
|
162
|
+
const localPath = getPublicPath(imageKey)
|
|
163
|
+
const fileBuffer = await fs.readFile(localPath)
|
|
164
|
+
|
|
165
|
+
await r2.send(
|
|
166
|
+
new PutObjectCommand({
|
|
167
|
+
Bucket: bucketName,
|
|
168
|
+
Key: imageKey.replace(/^\//, ''),
|
|
169
|
+
Body: fileBuffer,
|
|
170
|
+
ContentType: getContentType(imageKey),
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete original and thumbnails from R2 CDN
|
|
177
|
+
*/
|
|
178
|
+
export async function deleteFromCdn(imageKey: string, hasThumbnails: boolean): Promise<void> {
|
|
179
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
180
|
+
if (!bucketName) throw new Error('R2 bucket not configured')
|
|
181
|
+
|
|
182
|
+
const r2 = getR2Client()
|
|
183
|
+
|
|
184
|
+
// Delete original
|
|
185
|
+
try {
|
|
186
|
+
await r2.send(
|
|
187
|
+
new DeleteObjectCommand({
|
|
188
|
+
Bucket: bucketName,
|
|
189
|
+
Key: imageKey.replace(/^\//, ''),
|
|
190
|
+
})
|
|
191
|
+
)
|
|
192
|
+
} catch {
|
|
193
|
+
// May not exist
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Delete thumbnails if they exist
|
|
197
|
+
if (hasThumbnails) {
|
|
198
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
199
|
+
try {
|
|
200
|
+
await r2.send(
|
|
201
|
+
new DeleteObjectCommand({
|
|
202
|
+
Bucket: bucketName,
|
|
203
|
+
Key: thumbPath.replace(/^\//, ''),
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
} catch {
|
|
207
|
+
// May not exist
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Delete only thumbnails from R2 CDN (keeps original)
|
|
215
|
+
*/
|
|
216
|
+
export async function deleteThumbnailsFromCdn(imageKey: string): Promise<void> {
|
|
217
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
218
|
+
if (!bucketName) throw new Error('R2 bucket not configured')
|
|
219
|
+
|
|
220
|
+
const r2 = getR2Client()
|
|
221
|
+
|
|
222
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
223
|
+
try {
|
|
224
|
+
await r2.send(
|
|
225
|
+
new DeleteObjectCommand({
|
|
226
|
+
Bucket: bucketName,
|
|
227
|
+
Key: thumbPath.replace(/^\//, ''),
|
|
228
|
+
})
|
|
229
|
+
)
|
|
230
|
+
} catch {
|
|
231
|
+
// May not exist
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
export function isImageFile(filename: string): boolean {
|
|
5
|
+
const ext = path.extname(filename).toLowerCase()
|
|
6
|
+
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isMediaFile(filename: string): boolean {
|
|
10
|
+
const ext = path.extname(filename).toLowerCase()
|
|
11
|
+
// Images
|
|
12
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif'].includes(ext)) return true
|
|
13
|
+
// Videos
|
|
14
|
+
if (['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'].includes(ext)) return true
|
|
15
|
+
// Audio
|
|
16
|
+
if (['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'].includes(ext)) return true
|
|
17
|
+
// Documents/PDFs
|
|
18
|
+
if (['.pdf'].includes(ext)) return true
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getContentType(filePath: string): string {
|
|
23
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
24
|
+
switch (ext) {
|
|
25
|
+
case '.jpg':
|
|
26
|
+
case '.jpeg':
|
|
27
|
+
return 'image/jpeg'
|
|
28
|
+
case '.png':
|
|
29
|
+
return 'image/png'
|
|
30
|
+
case '.gif':
|
|
31
|
+
return 'image/gif'
|
|
32
|
+
case '.webp':
|
|
33
|
+
return 'image/webp'
|
|
34
|
+
case '.svg':
|
|
35
|
+
return 'image/svg+xml'
|
|
36
|
+
default:
|
|
37
|
+
return 'application/octet-stream'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getFolderStats(folderPath: string): Promise<{ fileCount: number; totalSize: number }> {
|
|
42
|
+
let fileCount = 0
|
|
43
|
+
let totalSize = 0
|
|
44
|
+
|
|
45
|
+
async function scanFolder(dir: string): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.name.startsWith('.')) continue
|
|
50
|
+
const fullPath = path.join(dir, entry.name)
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
await scanFolder(fullPath)
|
|
53
|
+
} else if (isMediaFile(entry.name)) {
|
|
54
|
+
fileCount++
|
|
55
|
+
const stats = await fs.stat(fullPath)
|
|
56
|
+
totalSize += stats.size
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch { /* ignore errors */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await scanFolder(folderPath)
|
|
63
|
+
return { fileCount, totalSize }
|
|
64
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { loadMeta, saveMeta, getCdnUrls, setCdnUrls, getOrAddCdnIndex, getMetaEntry, setMetaEntry, deleteMetaEntry, getFileEntries } from './meta'
|
|
2
|
+
export { isImageFile, isMediaFile, getContentType, getFolderStats } from './files'
|
|
3
|
+
export { processImage, DEFAULT_SIZES } from './thumbnails'
|
|
4
|
+
export { downloadFromCdn, uploadToCdn, deleteLocalThumbnails, downloadFromRemoteUrl, uploadOriginalToCdn, deleteFromCdn, deleteThumbnailsFromCdn, purgeCloudflareCache } from './cdn'
|