@kyro-cms/admin 0.1.6 → 0.1.7
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 +149 -51
- package/package.json +53 -6
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +136 -27
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +1417 -661
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +3 -3
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +199 -57
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +786 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +191 -53
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +149 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +97 -0
- package/src/components/blocks/ArrayBlock.tsx +75 -0
- package/src/components/blocks/BlockEditModal.MARKER +12 -0
- package/src/components/blocks/BlockEditModal.tsx +774 -0
- package/src/components/blocks/ButtonBlock.tsx +165 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +66 -0
- package/src/components/blocks/ColumnsBlock.tsx +151 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +64 -0
- package/src/components/blocks/HeadingBlock.tsx +81 -0
- package/src/components/blocks/HeroBlock.tsx +157 -0
- package/src/components/blocks/ImageBlock.tsx +83 -0
- package/src/components/blocks/LinkBlock.tsx +71 -0
- package/src/components/blocks/ListBlock.tsx +39 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +279 -0
- package/src/components/blocks/VStackBlock.tsx +75 -0
- package/src/components/blocks/VideoBlock.tsx +45 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/CheckboxField.tsx +15 -9
- package/src/components/fields/CodeField.tsx +234 -0
- package/src/components/fields/DateField.tsx +38 -11
- package/src/components/fields/EditorClient.tsx +271 -0
- package/src/components/fields/FileField.tsx +390 -0
- package/src/components/fields/HybridContentField.tsx +109 -0
- package/src/components/fields/ImageField.tsx +429 -0
- package/src/components/fields/JSONField.tsx +361 -0
- package/src/components/fields/MarkdownField.tsx +282 -0
- package/src/components/fields/NumberField.tsx +42 -12
- package/src/components/fields/PortableTextField.tsx +143 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipField.tsx +231 -59
- package/src/components/fields/SelectField.tsx +25 -15
- package/src/components/fields/TextField.tsx +45 -14
- package/src/components/fields/extensions/blockComponents.tsx +237 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +13 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +2 -2
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +22 -6
- package/src/lib/dataStore.ts +132 -74
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +44 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +36 -0
- package/src/pages/api/[collection]/[id].ts +102 -159
- package/src/pages/api/[collection]/index.ts +151 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +553 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { getStorageConfig } from "@/lib/storage";
|
|
4
|
+
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
|
5
|
+
|
|
6
|
+
function isCloudProvider(provider: string): boolean {
|
|
7
|
+
return [
|
|
8
|
+
"aws",
|
|
9
|
+
"r2",
|
|
10
|
+
"gcs",
|
|
11
|
+
"digitalocean",
|
|
12
|
+
"backblaze",
|
|
13
|
+
"wasabi",
|
|
14
|
+
"cloudinary",
|
|
15
|
+
].includes(provider);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractPublicDevUrlId(url?: string): string {
|
|
19
|
+
if (!url) return "";
|
|
20
|
+
if (url.startsWith("pub-")) return url;
|
|
21
|
+
const match = url.match(/pub-[a-zA-Z0-9]+/i);
|
|
22
|
+
return match ? match[0] : "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getCloudFolders(
|
|
26
|
+
provider: string,
|
|
27
|
+
config: any,
|
|
28
|
+
): Promise<string[]> {
|
|
29
|
+
const folders: string[] = [];
|
|
30
|
+
|
|
31
|
+
// Build S3-compatible client config
|
|
32
|
+
let s3Config: any = {
|
|
33
|
+
region: config.config.region || "auto",
|
|
34
|
+
credentials: {
|
|
35
|
+
accessKeyId: config.config.accessKeyId || "",
|
|
36
|
+
secretAccessKey: config.config.secretAccessKey || "",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Set endpoint based on provider
|
|
41
|
+
if (provider === "r2") {
|
|
42
|
+
// Use R2 API endpoint, not public dev URL
|
|
43
|
+
s3Config.endpoint = `https://${config.config.accountId}.r2.cloudflarestorage.com`;
|
|
44
|
+
s3Config.region = "auto";
|
|
45
|
+
s3Config.forcePathStyle = true;
|
|
46
|
+
} else if (provider === "digitalocean") {
|
|
47
|
+
s3Config.endpoint = `https://${config.config.region || "nyc3"}.digitaloceanspaces.com`;
|
|
48
|
+
} else if (provider === "backblaze") {
|
|
49
|
+
s3Config.endpoint = "https://s3.backblazeb2.com";
|
|
50
|
+
} else if (provider === "wasabi") {
|
|
51
|
+
s3Config.endpoint = `https://s3.${config.config.region || "us-east-1"}.wasabisys.com`;
|
|
52
|
+
} else if (provider === "gcs") {
|
|
53
|
+
s3Config.endpoint = "https://storage.googleapis.com";
|
|
54
|
+
} else {
|
|
55
|
+
// AWS S3
|
|
56
|
+
s3Config.region = config.config.region || "us-east-1";
|
|
57
|
+
if (config.config.endpoint) {
|
|
58
|
+
s3Config.endpoint = config.config.endpoint;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const client = new S3Client(s3Config);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const prefix = config.config.prefix || "";
|
|
66
|
+
const command = new ListObjectsV2Command({
|
|
67
|
+
Bucket: config.config.bucket,
|
|
68
|
+
Delimiter: "/",
|
|
69
|
+
Prefix: prefix ? `${prefix}/` : prefix,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const response = await client.send(command);
|
|
73
|
+
|
|
74
|
+
// Get common prefixes (folders)
|
|
75
|
+
if (response.CommonPrefixes) {
|
|
76
|
+
for (const cp of response.CommonPrefixes) {
|
|
77
|
+
if (cp.Prefix) {
|
|
78
|
+
// Remove the prefix from the path
|
|
79
|
+
let folderPath = cp.Prefix;
|
|
80
|
+
if (prefix && folderPath.startsWith(prefix + "/")) {
|
|
81
|
+
folderPath = folderPath.slice(prefix.length + 1);
|
|
82
|
+
}
|
|
83
|
+
// Remove trailing slash
|
|
84
|
+
folderPath = folderPath.replace(/\/$/, "");
|
|
85
|
+
if (folderPath) {
|
|
86
|
+
folders.push(folderPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Also check for folders at root level (without prefix)
|
|
93
|
+
if (!prefix) {
|
|
94
|
+
const rootCommand = new ListObjectsV2Command({
|
|
95
|
+
Bucket: config.config.bucket,
|
|
96
|
+
Delimiter: "/",
|
|
97
|
+
});
|
|
98
|
+
const rootResponse = await client.send(rootCommand);
|
|
99
|
+
|
|
100
|
+
if (rootResponse.CommonPrefixes) {
|
|
101
|
+
for (const cp of rootResponse.CommonPrefixes) {
|
|
102
|
+
if (cp.Prefix && cp.Prefix !== prefix) {
|
|
103
|
+
const folderPath = cp.Prefix.replace(/\/$/, "");
|
|
104
|
+
if (folderPath && !folders.includes(folderPath)) {
|
|
105
|
+
folders.push(folderPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error("[folders] Cloud API error:", error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return folders;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
120
|
+
try {
|
|
121
|
+
const storageConfig = await getStorageConfig();
|
|
122
|
+
console.log(
|
|
123
|
+
"[createFolder] storageConfig:",
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
provider: storageConfig.provider,
|
|
126
|
+
uploadDir: storageConfig.uploadDir,
|
|
127
|
+
baseUrl: storageConfig.baseUrl,
|
|
128
|
+
isCloud: isCloudProvider(storageConfig.provider),
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const { provider, config, uploadDir } = storageConfig;
|
|
132
|
+
|
|
133
|
+
const body = await request.json();
|
|
134
|
+
const { name, parentPath } = body;
|
|
135
|
+
|
|
136
|
+
if (!name) {
|
|
137
|
+
return new Response(JSON.stringify({ error: "Folder name required" }), {
|
|
138
|
+
status: 400,
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const path = parentPath ? `${parentPath}/${name}` : name;
|
|
144
|
+
|
|
145
|
+
// For cloud storage, folders are based on object key prefixes
|
|
146
|
+
// We can "create" a folder by uploading an empty placeholder object
|
|
147
|
+
if (isCloudProvider(provider)) {
|
|
148
|
+
// Save folder to database
|
|
149
|
+
try {
|
|
150
|
+
const Database = (await import("better-sqlite3")).default;
|
|
151
|
+
const db = new Database("./data/content.db");
|
|
152
|
+
|
|
153
|
+
const folderParentPath = parentPath ? `/${parentPath}` : null;
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
|
|
156
|
+
db.prepare(
|
|
157
|
+
`
|
|
158
|
+
INSERT OR IGNORE INTO media_folders (path, name, parent_path, created_at)
|
|
159
|
+
VALUES (?, ?, ?, ?)
|
|
160
|
+
`,
|
|
161
|
+
).run(`/${path}`, name, folderParentPath, now);
|
|
162
|
+
|
|
163
|
+
console.log("[createFolder] Saved folder to DB:", path);
|
|
164
|
+
db.close();
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
console.warn(
|
|
167
|
+
"[createFolder] Could not save folder to database:",
|
|
168
|
+
e.message,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Create folder in cloud storage
|
|
173
|
+
if (provider === "cloudinary") {
|
|
174
|
+
// Cloudinary folders are created automatically when uploading files
|
|
175
|
+
// The folder will exist once you upload your first file to it
|
|
176
|
+
console.log(
|
|
177
|
+
"[createFolder] Cloudinary: folder will be created on first upload",
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
// S3-compatible storage (R2, DO, etc.)
|
|
181
|
+
try {
|
|
182
|
+
const { S3Client, PutObjectCommand } =
|
|
183
|
+
await import("@aws-sdk/client-s3");
|
|
184
|
+
|
|
185
|
+
const s3Config: any = {
|
|
186
|
+
region: "auto",
|
|
187
|
+
credentials: {
|
|
188
|
+
accessKeyId: config.accessKeyId || "",
|
|
189
|
+
secretAccessKey: config.secretAccessKey || "",
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Set endpoint based on provider
|
|
194
|
+
if (provider === "r2") {
|
|
195
|
+
s3Config.endpoint = `https://${config.accountId}.r2.cloudflarestorage.com`;
|
|
196
|
+
s3Config.forcePathStyle = true;
|
|
197
|
+
} else if (provider === "digitalocean") {
|
|
198
|
+
s3Config.endpoint = `https://${config.region || "nyc3"}.digitaloceanspaces.com`;
|
|
199
|
+
} else if (provider === "backblaze") {
|
|
200
|
+
s3Config.endpoint = "https://s3.backblazeb2.com";
|
|
201
|
+
} else if (provider === "wasabi") {
|
|
202
|
+
s3Config.endpoint = `https://s3.${config.region || "us-east-1"}.wasabisys.com`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const s3Client = new S3Client(s3Config);
|
|
206
|
+
|
|
207
|
+
const folderKey = config.prefix
|
|
208
|
+
? `${config.prefix}/${path}/.folder`
|
|
209
|
+
: `${path}/.folder`;
|
|
210
|
+
|
|
211
|
+
await s3Client.send(
|
|
212
|
+
new PutObjectCommand({
|
|
213
|
+
Bucket: config.bucket,
|
|
214
|
+
Key: folderKey,
|
|
215
|
+
Body: Buffer.from(""),
|
|
216
|
+
ContentType: "application/x-directory",
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
console.log("[createFolder] Created folder in cloud:", folderKey);
|
|
221
|
+
} catch (cloudError: any) {
|
|
222
|
+
console.warn(
|
|
223
|
+
"[createFolder] Could not create folder in cloud:",
|
|
224
|
+
cloudError.message,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return new Response(
|
|
230
|
+
JSON.stringify({
|
|
231
|
+
success: true,
|
|
232
|
+
folder: {
|
|
233
|
+
id: path,
|
|
234
|
+
name,
|
|
235
|
+
path: `/${path}`,
|
|
236
|
+
createdAt: new Date().toISOString(),
|
|
237
|
+
},
|
|
238
|
+
cloudStorage: true,
|
|
239
|
+
}),
|
|
240
|
+
{
|
|
241
|
+
status: 201,
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Local storage - create physical folder
|
|
248
|
+
const { join } = await import("path");
|
|
249
|
+
const folderPath = parentPath
|
|
250
|
+
? join(uploadDir, parentPath, name)
|
|
251
|
+
: join(uploadDir, name);
|
|
252
|
+
|
|
253
|
+
await fs.mkdir(folderPath, { recursive: true });
|
|
254
|
+
|
|
255
|
+
const newFolder = {
|
|
256
|
+
id: name,
|
|
257
|
+
name,
|
|
258
|
+
path: parentPath ? `/${parentPath}/${name}` : `/${name}`,
|
|
259
|
+
createdAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return new Response(JSON.stringify({ success: true, folder: newFolder }), {
|
|
263
|
+
status: 201,
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error("Create folder error:", error);
|
|
268
|
+
return new Response(JSON.stringify({ error: "Failed to create folder" }), {
|
|
269
|
+
status: 500,
|
|
270
|
+
headers: { "Content-Type": "application/json" },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const GET: APIRoute = async () => {
|
|
276
|
+
try {
|
|
277
|
+
const storageConfig = await getStorageConfig();
|
|
278
|
+
const { provider, config, uploadDir } = storageConfig;
|
|
279
|
+
const folders: string[] = [];
|
|
280
|
+
|
|
281
|
+
// Check if it's cloud storage
|
|
282
|
+
if (isCloudProvider(provider)) {
|
|
283
|
+
// First try to get folders from database
|
|
284
|
+
try {
|
|
285
|
+
const Database = (await import("better-sqlite3")).default;
|
|
286
|
+
const db = new Database("./data/content.db");
|
|
287
|
+
const dbFolders = db
|
|
288
|
+
.prepare("SELECT path FROM media_folders ORDER BY path")
|
|
289
|
+
.all() as { path: string }[];
|
|
290
|
+
db.close();
|
|
291
|
+
|
|
292
|
+
// For Cloudinary, folders are only tracked in DB (not real folders)
|
|
293
|
+
// For S3-compatible storage, try cloud API if DB is empty
|
|
294
|
+
if (provider === "cloudinary" || dbFolders.length > 0) {
|
|
295
|
+
return new Response(
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
folders: dbFolders.map((f) => f.path.replace(/^\//, "")).sort(),
|
|
298
|
+
cloudStorage: true,
|
|
299
|
+
provider,
|
|
300
|
+
}),
|
|
301
|
+
{
|
|
302
|
+
status: 200,
|
|
303
|
+
headers: { "Content-Type": "application/json" },
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} catch (e: any) {
|
|
308
|
+
console.warn("[getFolders] Could not read from DB:", e.message);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// For Cloudinary, no cloud API to call - just return empty
|
|
312
|
+
if (provider === "cloudinary") {
|
|
313
|
+
return new Response(
|
|
314
|
+
JSON.stringify({
|
|
315
|
+
folders: [],
|
|
316
|
+
cloudStorage: true,
|
|
317
|
+
provider,
|
|
318
|
+
}),
|
|
319
|
+
{
|
|
320
|
+
status: 200,
|
|
321
|
+
headers: { "Content-Type": "application/json" },
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// For S3-compatible storage, try cloud API
|
|
327
|
+
try {
|
|
328
|
+
const cloudFolders = await getCloudFolders(provider, storageConfig);
|
|
329
|
+
return new Response(
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
folders: cloudFolders.sort(),
|
|
332
|
+
cloudStorage: true,
|
|
333
|
+
provider,
|
|
334
|
+
}),
|
|
335
|
+
{
|
|
336
|
+
status: 200,
|
|
337
|
+
headers: { "Content-Type": "application/json" },
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
} catch (cloudError) {
|
|
341
|
+
console.error("Get cloud folders error:", cloudError);
|
|
342
|
+
return new Response(
|
|
343
|
+
JSON.stringify({
|
|
344
|
+
folders: [],
|
|
345
|
+
error: "Failed to fetch cloud storage folders",
|
|
346
|
+
cloudStorage: true,
|
|
347
|
+
}),
|
|
348
|
+
{
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: { "Content-Type": "application/json" },
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Local storage - scan filesystem
|
|
357
|
+
const { join } = await import("path");
|
|
358
|
+
|
|
359
|
+
async function scanDir(dirPath: string, currentPrefix = "") {
|
|
360
|
+
try {
|
|
361
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
362
|
+
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
365
|
+
const folderPath = currentPrefix
|
|
366
|
+
? `${currentPrefix}/${entry.name}`
|
|
367
|
+
: entry.name;
|
|
368
|
+
folders.push(folderPath);
|
|
369
|
+
await scanDir(join(dirPath, entry.name), folderPath);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
// Directory doesn't exist or is empty
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await fs.mkdir(uploadDir, { recursive: true });
|
|
378
|
+
await scanDir(uploadDir);
|
|
379
|
+
|
|
380
|
+
return new Response(JSON.stringify({ folders: folders.sort() }), {
|
|
381
|
+
status: 200,
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
});
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.error("Get folders error:", error);
|
|
386
|
+
return new Response(JSON.stringify({ folders: [] }), {
|
|
387
|
+
status: 200,
|
|
388
|
+
headers: { "Content-Type": "application/json" },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export const DELETE: APIRoute = async ({ request }) => {
|
|
394
|
+
try {
|
|
395
|
+
const storageConfig = await getStorageConfig();
|
|
396
|
+
const { provider, config, uploadDir } = storageConfig;
|
|
397
|
+
const url = new URL(request.url);
|
|
398
|
+
const folderPath = url.searchParams.get("path");
|
|
399
|
+
|
|
400
|
+
if (!folderPath) {
|
|
401
|
+
return new Response(JSON.stringify({ error: "Folder path required" }), {
|
|
402
|
+
status: 400,
|
|
403
|
+
headers: { "Content-Type": "application/json" },
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// For cloud storage, we can't delete "folders" - they're just prefixes
|
|
408
|
+
// We need to delete all objects with that prefix
|
|
409
|
+
if (isCloudProvider(provider)) {
|
|
410
|
+
// Delete folder from database
|
|
411
|
+
try {
|
|
412
|
+
const Database = (await import("better-sqlite3")).default;
|
|
413
|
+
const db = new Database("./data/content.db");
|
|
414
|
+
|
|
415
|
+
// Delete from media_folders table
|
|
416
|
+
db.prepare(
|
|
417
|
+
"DELETE FROM media_folders WHERE path = ? OR path LIKE ?",
|
|
418
|
+
).run(`/${folderPath}`, `/${folderPath}/%`);
|
|
419
|
+
|
|
420
|
+
// Delete from media table - this removes the database records so UI updates
|
|
421
|
+
db.prepare("DELETE FROM media WHERE folder = ? OR folder LIKE ?").run(
|
|
422
|
+
`/${folderPath}`,
|
|
423
|
+
`/${folderPath}/%`,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
db.close();
|
|
427
|
+
} catch (e: any) {
|
|
428
|
+
console.warn("[deleteFolder] Could not delete from DB:", e.message);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Delete folder from cloud storage
|
|
432
|
+
if (provider === "cloudinary") {
|
|
433
|
+
try {
|
|
434
|
+
const cloudName = config.cloudName;
|
|
435
|
+
const apiKey = config.apiKey;
|
|
436
|
+
const apiSecret = config.apiSecret;
|
|
437
|
+
|
|
438
|
+
console.log("[deleteFolder] Cloudinary config:", {
|
|
439
|
+
cloudName,
|
|
440
|
+
apiKey: !!apiKey,
|
|
441
|
+
apiSecret: !!apiSecret,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!cloudName || !apiKey || !apiSecret) {
|
|
445
|
+
console.warn("[deleteFolder] Missing Cloudinary config");
|
|
446
|
+
} else {
|
|
447
|
+
// Admin API uses Basic Authentication
|
|
448
|
+
const auth = Buffer.from(`${apiKey}:${apiSecret}`).toString(
|
|
449
|
+
"base64",
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// First delete all resources in the folder (Cloudinary requires folder to be empty)
|
|
453
|
+
console.log(
|
|
454
|
+
"[deleteFolder] Deleting resources with prefix:",
|
|
455
|
+
folderPath,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const deleteResponse = await fetch(
|
|
459
|
+
`https://api.cloudinary.com/v1_1/${cloudName}/resources/image/upload?prefix=${encodeURIComponent(folderPath)}`,
|
|
460
|
+
{
|
|
461
|
+
method: "DELETE",
|
|
462
|
+
headers: {
|
|
463
|
+
Authorization: `Basic ${auth}`,
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const deleteResult = await deleteResponse.text();
|
|
469
|
+
console.log(
|
|
470
|
+
"[deleteFolder] Delete resources response:",
|
|
471
|
+
deleteResponse.status,
|
|
472
|
+
deleteResult,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
// Now delete the empty folder
|
|
476
|
+
console.log("[deleteFolder] Deleting folder:", folderPath);
|
|
477
|
+
|
|
478
|
+
const folderResponse = await fetch(
|
|
479
|
+
`https://api.cloudinary.com/v1_1/${cloudName}/folders/${folderPath}`,
|
|
480
|
+
{
|
|
481
|
+
method: "DELETE",
|
|
482
|
+
headers: {
|
|
483
|
+
Authorization: `Basic ${auth}`,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const folderResult = await folderResponse.text();
|
|
489
|
+
console.log(
|
|
490
|
+
"[deleteFolder] Delete folder response:",
|
|
491
|
+
folderResponse.status,
|
|
492
|
+
folderResult,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
console.log(
|
|
496
|
+
"[deleteFolder] Deleted folder and contents from Cloudinary:",
|
|
497
|
+
folderPath,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
} catch (cloudError: any) {
|
|
501
|
+
console.warn("[deleteFolder] Cloudinary error:", cloudError.message);
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
// S3-compatible storage (R2, DO, etc.)
|
|
505
|
+
try {
|
|
506
|
+
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } =
|
|
507
|
+
await import("@aws-sdk/client-s3");
|
|
508
|
+
|
|
509
|
+
const s3Config: any = {
|
|
510
|
+
region: "auto",
|
|
511
|
+
credentials: {
|
|
512
|
+
accessKeyId: config.accessKeyId || "",
|
|
513
|
+
secretAccessKey: config.secretAccessKey || "",
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
if (provider === "r2") {
|
|
518
|
+
s3Config.endpoint = `https://${config.accountId}.r2.cloudflarestorage.com`;
|
|
519
|
+
s3Config.forcePathStyle = true;
|
|
520
|
+
} else if (provider === "digitalocean") {
|
|
521
|
+
s3Config.endpoint = `https://${config.region || "nyc3"}.digitaloceanspaces.com`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const s3Client = new S3Client(s3Config);
|
|
525
|
+
const prefix = config.prefix ? `${config.prefix}/` : "";
|
|
526
|
+
const folderPrefix = `${prefix}${folderPath}/`;
|
|
527
|
+
|
|
528
|
+
// List and delete all objects with the prefix
|
|
529
|
+
let continuationToken: string | undefined;
|
|
530
|
+
do {
|
|
531
|
+
const listResponse = await s3Client.send(
|
|
532
|
+
new ListObjectsV2Command({
|
|
533
|
+
Bucket: config.bucket,
|
|
534
|
+
Prefix: folderPrefix,
|
|
535
|
+
ContinuationToken: continuationToken,
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
if (listResponse.Contents) {
|
|
540
|
+
for (const obj of listResponse.Contents) {
|
|
541
|
+
if (obj.Key) {
|
|
542
|
+
await s3Client.send(
|
|
543
|
+
new DeleteObjectCommand({
|
|
544
|
+
Bucket: config.bucket,
|
|
545
|
+
Key: obj.Key,
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
continuationToken = listResponse.NextContinuationToken;
|
|
552
|
+
} while (continuationToken);
|
|
553
|
+
|
|
554
|
+
console.log(
|
|
555
|
+
"[deleteFolder] Deleted folder from cloud:",
|
|
556
|
+
folderPrefix,
|
|
557
|
+
);
|
|
558
|
+
} catch (cloudError: any) {
|
|
559
|
+
console.warn(
|
|
560
|
+
"[deleteFolder] Could not delete from cloud:",
|
|
561
|
+
cloudError.message,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
567
|
+
status: 200,
|
|
568
|
+
headers: { "Content-Type": "application/json" },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Local storage - delete physical folder
|
|
573
|
+
const { join } = await import("path");
|
|
574
|
+
const fullPath = join(uploadDir, folderPath);
|
|
575
|
+
await fs.rm(fullPath, { recursive: true, force: true });
|
|
576
|
+
|
|
577
|
+
// Clean up database records
|
|
578
|
+
try {
|
|
579
|
+
const { getDatabaseConfig } = await import("@/lib/db");
|
|
580
|
+
const dbConfig = getDatabaseConfig();
|
|
581
|
+
|
|
582
|
+
if (dbConfig.type === "sqlite") {
|
|
583
|
+
const Database = (await import("better-sqlite3")).default;
|
|
584
|
+
const db = new Database(dbConfig.contentDbPath || "./data/content.db");
|
|
585
|
+
try {
|
|
586
|
+
db.prepare("DELETE FROM media WHERE folder = ? OR folder LIKE ?").run(
|
|
587
|
+
folderPath,
|
|
588
|
+
`${folderPath}/%`,
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
db.close();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} catch (dbError) {
|
|
595
|
+
console.warn("[folders DELETE] Failed to clean DB records:", dbError);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
599
|
+
status: 200,
|
|
600
|
+
headers: { "Content-Type": "application/json" },
|
|
601
|
+
});
|
|
602
|
+
} catch (error) {
|
|
603
|
+
console.error("Delete folder error:", error);
|
|
604
|
+
return new Response(JSON.stringify({ error: "Failed to delete folder" }), {
|
|
605
|
+
status: 500,
|
|
606
|
+
headers: { "Content-Type": "application/json" },
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
};
|