@kyro-cms/admin 0.1.5 → 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 +52 -5
- 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 +50 -0
- 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 +116 -28
- 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 +286 -0
- 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 +50 -20
- 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 +82 -0
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +102 -0
- 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
- package/src/pages/index.astro +0 -225
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getMediaService, type MediaItem } from "../../../lib/MediaService";
|
|
3
|
+
import { constructMediaUrl, getStorageConfig } from "@/lib/storage";
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
6
|
+
let mediaService: any = null;
|
|
7
|
+
try {
|
|
8
|
+
mediaService = await getMediaService();
|
|
9
|
+
|
|
10
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
11
|
+
const limit = parseInt(url.searchParams.get("limit") || "30");
|
|
12
|
+
const search = url.searchParams.get("search") || "";
|
|
13
|
+
const type = url.searchParams.get("type") || "";
|
|
14
|
+
const folder = url.searchParams.get("folder") || "";
|
|
15
|
+
const sortBy = url.searchParams.get("sortBy") || "createdAt";
|
|
16
|
+
const sortDir = (url.searchParams.get("sortDir") || "desc") as
|
|
17
|
+
| "asc"
|
|
18
|
+
| "desc";
|
|
19
|
+
|
|
20
|
+
const result = await mediaService.find({
|
|
21
|
+
page,
|
|
22
|
+
limit,
|
|
23
|
+
search: search || undefined,
|
|
24
|
+
type: type || undefined,
|
|
25
|
+
folder: folder || undefined,
|
|
26
|
+
sortBy,
|
|
27
|
+
sortDir,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Get storage config to determine provider type
|
|
31
|
+
const storageConfig = await getStorageConfig();
|
|
32
|
+
const isLocalStorage = storageConfig.provider === "local";
|
|
33
|
+
|
|
34
|
+
// Compute URLs dynamically from storage settings
|
|
35
|
+
const isCloudStorage = !isLocalStorage;
|
|
36
|
+
const docs = await Promise.all(
|
|
37
|
+
result.docs.map(async (doc: MediaItem) => {
|
|
38
|
+
// For cloud storage, use the stored URL from DB directly
|
|
39
|
+
// For local storage, construct URL from baseUrl + filename
|
|
40
|
+
let mediaUrl: string;
|
|
41
|
+
if (isCloudStorage) {
|
|
42
|
+
// Cloudinary already stores full URL, use it directly from DB
|
|
43
|
+
mediaUrl = doc.url || (await constructMediaUrl(doc.filename, null));
|
|
44
|
+
} else {
|
|
45
|
+
mediaUrl = await constructMediaUrl(doc.filename, doc.folder);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// For local storage, use resize API. For cloud storage, use direct URL
|
|
49
|
+
let thumbnailUrl: string | undefined;
|
|
50
|
+
if (doc.mimeType?.startsWith("image/")) {
|
|
51
|
+
if (isLocalStorage) {
|
|
52
|
+
// Local storage: use resize API
|
|
53
|
+
thumbnailUrl = `/api/media/resize?url=${encodeURIComponent(mediaUrl)}&w=400&h=400`;
|
|
54
|
+
} else {
|
|
55
|
+
// Cloud storage: use the same URL
|
|
56
|
+
thumbnailUrl = mediaUrl;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: doc.id,
|
|
62
|
+
title: doc.title || doc.filename,
|
|
63
|
+
filename: doc.filename,
|
|
64
|
+
originalName: doc.originalName,
|
|
65
|
+
url: mediaUrl,
|
|
66
|
+
thumbnailUrl,
|
|
67
|
+
type: doc.mimeType?.split("/")[0] || "other",
|
|
68
|
+
mimeType: doc.mimeType,
|
|
69
|
+
fileSize: doc.fileSize,
|
|
70
|
+
folder: doc.folder,
|
|
71
|
+
alt: doc.alt,
|
|
72
|
+
caption: doc.caption,
|
|
73
|
+
createdAt: doc.createdAt,
|
|
74
|
+
updatedAt: doc.updatedAt,
|
|
75
|
+
};
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return new Response(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
docs,
|
|
82
|
+
totalDocs: result.totalDocs,
|
|
83
|
+
page: result.page,
|
|
84
|
+
limit: result.limit,
|
|
85
|
+
totalPages: result.totalPages,
|
|
86
|
+
}),
|
|
87
|
+
{
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
} catch (error: any) {
|
|
93
|
+
console.error("[media API] Error:", error?.message || error);
|
|
94
|
+
return new Response(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
error: error?.message || "Failed to fetch media",
|
|
97
|
+
docs: [],
|
|
98
|
+
totalDocs: 0,
|
|
99
|
+
}),
|
|
100
|
+
{
|
|
101
|
+
status: 500,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const PATCH: APIRoute = async ({ request }) => {
|
|
109
|
+
try {
|
|
110
|
+
const mediaService = await getMediaService();
|
|
111
|
+
const body = await request.json();
|
|
112
|
+
const { id, ...data } = body;
|
|
113
|
+
|
|
114
|
+
const updated = await mediaService.update(id, data);
|
|
115
|
+
|
|
116
|
+
return new Response(JSON.stringify(updated), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
});
|
|
120
|
+
} catch (error: any) {
|
|
121
|
+
return new Response(
|
|
122
|
+
JSON.stringify({ error: error.message || "Failed to update media" }),
|
|
123
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const DELETE: APIRoute = async ({ request }) => {
|
|
129
|
+
try {
|
|
130
|
+
const mediaService = await getMediaService();
|
|
131
|
+
const body = await request.json();
|
|
132
|
+
const { id } = body;
|
|
133
|
+
|
|
134
|
+
await mediaService.delete(id);
|
|
135
|
+
|
|
136
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
});
|
|
140
|
+
} catch (error: any) {
|
|
141
|
+
return new Response(
|
|
142
|
+
JSON.stringify({ error: error.message || "Failed to delete media" }),
|
|
143
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import fsSync from "fs";
|
|
6
|
+
import https from "https";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import { getStorageConfig } from "@/lib/storage";
|
|
9
|
+
|
|
10
|
+
// Cache configuration
|
|
11
|
+
const CACHE_BASE = path.join(process.cwd(), ".cache", "kyro-media", "resize");
|
|
12
|
+
const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
13
|
+
const MAX_CACHE_AGE_HOURS = 24 * 7; // 7 days
|
|
14
|
+
|
|
15
|
+
// Ensure cache directory exists
|
|
16
|
+
async function ensureCacheDir() {
|
|
17
|
+
try {
|
|
18
|
+
await fs.mkdir(CACHE_BASE, { recursive: true });
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Generate cache key from URL and parameters
|
|
23
|
+
function getCacheKey(
|
|
24
|
+
imageUrl: string,
|
|
25
|
+
width: number,
|
|
26
|
+
height: number,
|
|
27
|
+
quality: number,
|
|
28
|
+
format: string,
|
|
29
|
+
): string {
|
|
30
|
+
const input = `${imageUrl}-${width}-${height}-${quality}-${format}`;
|
|
31
|
+
return createHash("md5").update(input).digest("hex") + `.${format}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Get cache file path
|
|
35
|
+
function getCachePath(key: string): string {
|
|
36
|
+
return path.join(CACHE_BASE, key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Clean up old cache files when exceeding max size
|
|
40
|
+
async function cleanCache(): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await fs.readdir(CACHE_BASE, { withFileTypes: true });
|
|
43
|
+
const files = entries
|
|
44
|
+
.filter((e) => e.isFile())
|
|
45
|
+
.map((e) => ({
|
|
46
|
+
name: e.name,
|
|
47
|
+
path: path.join(CACHE_BASE, e.name),
|
|
48
|
+
mtime: fsSync.statSync(path.join(CACHE_BASE, e.name)).mtime,
|
|
49
|
+
size: fsSync.statSync(path.join(CACHE_BASE, e.name)).size,
|
|
50
|
+
}))
|
|
51
|
+
.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); // oldest first
|
|
52
|
+
|
|
53
|
+
let totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
54
|
+
const maxSize = MAX_CACHE_SIZE;
|
|
55
|
+
|
|
56
|
+
// Delete oldest files until under limit
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (totalSize < maxSize) break;
|
|
59
|
+
await fs.unlink(file.path);
|
|
60
|
+
totalSize -= file.size;
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if cache is valid (auto-invalidation on source modification)
|
|
66
|
+
async function isCacheValid(
|
|
67
|
+
cachePath: string,
|
|
68
|
+
sourcePath: string,
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
const cacheStat = await fs.stat(cachePath);
|
|
72
|
+
const sourceStat = await fs.stat(sourcePath);
|
|
73
|
+
|
|
74
|
+
// Cache invalid if source is newer than cache
|
|
75
|
+
if (sourceStat.mtimeMs > cacheStat.mtimeMs) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Also check cache age
|
|
80
|
+
const cacheAgeHours = (Date.now() - cacheStat.mtimeMs) / (1000 * 60 * 60);
|
|
81
|
+
if (cacheAgeHours > MAX_CACHE_AGE_HOURS) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Get source file path for local files
|
|
92
|
+
async function getSourcePath(
|
|
93
|
+
imageUrl: string,
|
|
94
|
+
uploadDir: string,
|
|
95
|
+
baseUrl: string,
|
|
96
|
+
): Promise<string | null> {
|
|
97
|
+
try {
|
|
98
|
+
let relativePath = imageUrl;
|
|
99
|
+
if (baseUrl !== "/" && imageUrl.startsWith(baseUrl)) {
|
|
100
|
+
relativePath = imageUrl.slice(baseUrl.length);
|
|
101
|
+
} else if (baseUrl === "/") {
|
|
102
|
+
relativePath = imageUrl.slice(1);
|
|
103
|
+
}
|
|
104
|
+
return path.join(uploadDir, relativePath);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// HTTP agent for remote files
|
|
111
|
+
const httpsAgent = new https.Agent({
|
|
112
|
+
keepAlive: true,
|
|
113
|
+
timeout: 30000,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Fetch with custom agent
|
|
117
|
+
async function fetchWithAgent(url: string): Promise<Buffer> {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const options = {
|
|
121
|
+
hostname: parsed.hostname,
|
|
122
|
+
port: 443,
|
|
123
|
+
path: parsed.pathname + parsed.search,
|
|
124
|
+
method: "GET",
|
|
125
|
+
agent: httpsAgent,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const req = https.request(options, (res) => {
|
|
129
|
+
if (
|
|
130
|
+
res.statusCode &&
|
|
131
|
+
res.statusCode >= 300 &&
|
|
132
|
+
res.statusCode < 400 &&
|
|
133
|
+
res.headers.location
|
|
134
|
+
) {
|
|
135
|
+
fetchWithAgent(res.headers.location).then(resolve).catch(reject);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
139
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const chunks: Buffer[] = [];
|
|
144
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
145
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
146
|
+
res.on("error", reject);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
req.on("error", reject);
|
|
150
|
+
req.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
155
|
+
const refresh = url.searchParams.get("refresh") === "true";
|
|
156
|
+
let imageUrl = url.searchParams.get("url");
|
|
157
|
+
const width = parseInt(url.searchParams.get("w") || "0");
|
|
158
|
+
const height = parseInt(url.searchParams.get("h") || "0");
|
|
159
|
+
const quality = parseInt(url.searchParams.get("q") || "80");
|
|
160
|
+
const format = url.searchParams.get("f") || "webp";
|
|
161
|
+
|
|
162
|
+
if (!imageUrl) return new Response("Missing URL", { status: 400 });
|
|
163
|
+
|
|
164
|
+
// Prevent double-slash URLs
|
|
165
|
+
if (!imageUrl.startsWith("http")) {
|
|
166
|
+
imageUrl = imageUrl.replace(/^\/+/, "/");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { uploadDir, baseUrl } = await getStorageConfig();
|
|
170
|
+
const cacheKey = getCacheKey(imageUrl, width, height, quality, format);
|
|
171
|
+
const cachePath = getCachePath(cacheKey);
|
|
172
|
+
|
|
173
|
+
// Ensure cache directory exists
|
|
174
|
+
await ensureCacheDir();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
let input: Buffer | null = null;
|
|
178
|
+
let useCache = false;
|
|
179
|
+
|
|
180
|
+
// Check cache first (if not refreshing)
|
|
181
|
+
if (!refresh) {
|
|
182
|
+
try {
|
|
183
|
+
const sourcePath = await getSourcePath(imageUrl, uploadDir, baseUrl);
|
|
184
|
+
|
|
185
|
+
if (sourcePath && (await isCacheValid(cachePath, sourcePath))) {
|
|
186
|
+
// Use cache
|
|
187
|
+
input = await fs.readFile(cachePath);
|
|
188
|
+
useCache = true;
|
|
189
|
+
console.log("[resize] Cache hit:", cacheKey);
|
|
190
|
+
}
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// If not using cache, fetch and process
|
|
195
|
+
if (input === null) {
|
|
196
|
+
// Fetch original
|
|
197
|
+
if (imageUrl.startsWith("/")) {
|
|
198
|
+
// Local file
|
|
199
|
+
let relativePath = imageUrl;
|
|
200
|
+
if (baseUrl !== "/" && imageUrl.startsWith(baseUrl)) {
|
|
201
|
+
relativePath = imageUrl.slice(baseUrl.length);
|
|
202
|
+
} else if (baseUrl === "/") {
|
|
203
|
+
relativePath = imageUrl.slice(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const localPath = path.join(uploadDir, relativePath);
|
|
207
|
+
input = await fs.readFile(localPath);
|
|
208
|
+
} else {
|
|
209
|
+
// Remote file (S3/R2)
|
|
210
|
+
input = await fetchWithAgent(imageUrl);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Process
|
|
214
|
+
let pipeline = sharp(input);
|
|
215
|
+
if (width > 0 || height > 0) {
|
|
216
|
+
pipeline = pipeline.resize(width || undefined, height || undefined, {
|
|
217
|
+
fit: "cover",
|
|
218
|
+
withoutEnlargement: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (format === "webp") pipeline = pipeline.webp({ quality });
|
|
223
|
+
else if (format === "avif") pipeline = pipeline.avif({ quality });
|
|
224
|
+
else if (format === "jpg" || format === "jpeg")
|
|
225
|
+
pipeline = pipeline.jpeg({ quality });
|
|
226
|
+
|
|
227
|
+
const output = await pipeline.toBuffer();
|
|
228
|
+
|
|
229
|
+
// Write to cache
|
|
230
|
+
await fs.writeFile(cachePath, output);
|
|
231
|
+
console.log("[resize] Cached:", cacheKey);
|
|
232
|
+
|
|
233
|
+
// Clean cache if needed
|
|
234
|
+
await cleanCache();
|
|
235
|
+
|
|
236
|
+
input = output;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!input) {
|
|
240
|
+
throw new Error("Failed to process image");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return new Response(input as any, {
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": `image/${format}`,
|
|
246
|
+
"Cache-Control": "public, max-age=86400", // 1 day browser cache
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error("Resize error:", error);
|
|
251
|
+
|
|
252
|
+
// Redirect to original on error
|
|
253
|
+
if (imageUrl) {
|
|
254
|
+
if (imageUrl.startsWith("http")) return Response.redirect(imageUrl, 302);
|
|
255
|
+
try {
|
|
256
|
+
const origin = url.origin;
|
|
257
|
+
return Response.redirect(
|
|
258
|
+
`${origin}${imageUrl.startsWith("/") ? "" : "/"}${imageUrl}`,
|
|
259
|
+
302,
|
|
260
|
+
);
|
|
261
|
+
} catch {
|
|
262
|
+
return new Response("Error processing image", { status: 500 });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new Response("Error processing image", { status: 500 });
|
|
266
|
+
}
|
|
267
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
6
|
+
const query = url.searchParams.get("q") || "";
|
|
7
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 50);
|
|
8
|
+
|
|
9
|
+
if (!query || query.length < 2) {
|
|
10
|
+
return new Response(JSON.stringify({ results: [] }), {
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
17
|
+
const results: Array<{
|
|
18
|
+
collection: string;
|
|
19
|
+
label: string;
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
}> = [];
|
|
23
|
+
|
|
24
|
+
const demoResults = [
|
|
25
|
+
{
|
|
26
|
+
collection: "posts",
|
|
27
|
+
label: "Posts",
|
|
28
|
+
id: "1",
|
|
29
|
+
title: "Welcome to Kyro CMS",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
collection: "posts",
|
|
33
|
+
label: "Posts",
|
|
34
|
+
id: "2",
|
|
35
|
+
title: "Getting Started Guide",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
collection: "posts",
|
|
39
|
+
label: "Posts",
|
|
40
|
+
id: "3",
|
|
41
|
+
title: "Advanced Features",
|
|
42
|
+
},
|
|
43
|
+
{ collection: "pages", label: "Pages", id: "about", title: "About Us" },
|
|
44
|
+
{ collection: "pages", label: "Pages", id: "contact", title: "Contact" },
|
|
45
|
+
{
|
|
46
|
+
collection: "products",
|
|
47
|
+
label: "Products",
|
|
48
|
+
id: "prod-1",
|
|
49
|
+
title: "Sample Product",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
collection: "products",
|
|
53
|
+
label: "Products",
|
|
54
|
+
id: "prod-2",
|
|
55
|
+
title: "Premium Plan",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
collection: "categories",
|
|
59
|
+
label: "Categories",
|
|
60
|
+
id: "cat-1",
|
|
61
|
+
title: "Technology",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
collection: "categories",
|
|
65
|
+
label: "Categories",
|
|
66
|
+
id: "cat-2",
|
|
67
|
+
title: "Business",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const doc of demoResults) {
|
|
72
|
+
if (regex.test(doc.title)) {
|
|
73
|
+
results.push(doc);
|
|
74
|
+
if (results.length >= limit) break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Response(JSON.stringify({ results: results.slice(0, limit) }), {
|
|
79
|
+
status: 200,
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
});
|
|
82
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { dataStore } from "@/lib/dataStore";
|
|
3
|
+
import { collections } from "@/lib/config";
|
|
4
|
+
|
|
5
|
+
dataStore.initialize(collections);
|
|
6
|
+
|
|
7
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
8
|
+
try {
|
|
9
|
+
const body = await request.json();
|
|
10
|
+
const { collection, slug } = body;
|
|
11
|
+
|
|
12
|
+
if (!collection || !slug) {
|
|
13
|
+
return new Response(
|
|
14
|
+
JSON.stringify({ error: "Collection and slug are required" }),
|
|
15
|
+
{
|
|
16
|
+
status: 400,
|
|
17
|
+
headers: { "Content-Type": "application/json" },
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!collections[collection]) {
|
|
23
|
+
return new Response(JSON.stringify({ error: "Invalid collection" }), {
|
|
24
|
+
status: 404,
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if slug exists in collection
|
|
30
|
+
const existing = await dataStore.findOne(collection, { slug });
|
|
31
|
+
|
|
32
|
+
if (existing) {
|
|
33
|
+
// Generate a unique suggestion
|
|
34
|
+
const baseSlug = slug.replace(/-[a-z0-9]+$/i, "");
|
|
35
|
+
const suggested = `${baseSlug}-${Math.random().toString(36).slice(2, 6)}`;
|
|
36
|
+
|
|
37
|
+
return new Response(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
available: false,
|
|
40
|
+
slug,
|
|
41
|
+
suggested,
|
|
42
|
+
}),
|
|
43
|
+
{
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new Response(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
available: true,
|
|
53
|
+
slug,
|
|
54
|
+
}),
|
|
55
|
+
{
|
|
56
|
+
status: 200,
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error("Slug availability check error:", error);
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({ error: "Failed to check slug availability" }),
|
|
64
|
+
{
|
|
65
|
+
status: 500,
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getStorageConfig } from "@/lib/storage";
|
|
3
|
+
|
|
4
|
+
export const GET: APIRoute = async () => {
|
|
5
|
+
try {
|
|
6
|
+
const config = await getStorageConfig();
|
|
7
|
+
return new Response(JSON.stringify(config), {
|
|
8
|
+
status: 200,
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
});
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return new Response(
|
|
13
|
+
JSON.stringify({
|
|
14
|
+
error: "Failed to get storage config",
|
|
15
|
+
details: String(error),
|
|
16
|
+
}),
|
|
17
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
};
|