@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,267 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
|
|
4
|
+
const DB_PATH =
|
|
5
|
+
process.env.KYRO_AUTH_DB_PATH || process.env.KYRO_DB_PATH || "./data/auth.db";
|
|
6
|
+
|
|
7
|
+
interface RateLimitConfig {
|
|
8
|
+
maxAttempts: number;
|
|
9
|
+
windowMs: number;
|
|
10
|
+
lockoutMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RateLimitResult {
|
|
14
|
+
allowed: boolean;
|
|
15
|
+
remaining: number;
|
|
16
|
+
resetAt: Date | null;
|
|
17
|
+
lockedUntil: Date | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONFIG: RateLimitConfig = {
|
|
21
|
+
maxAttempts: 5,
|
|
22
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
23
|
+
lockoutMs: 15 * 60 * 1000, // 15 minutes lockout
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function getDb() {
|
|
27
|
+
return new Database(DB_PATH);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function checkRateLimit(
|
|
31
|
+
identifier: string,
|
|
32
|
+
action: string = "login",
|
|
33
|
+
config: RateLimitConfig = DEFAULT_CONFIG,
|
|
34
|
+
): Promise<RateLimitResult> {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const now = new Date();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Clean up expired records first
|
|
40
|
+
db.prepare(
|
|
41
|
+
`
|
|
42
|
+
DELETE FROM rate_limits
|
|
43
|
+
WHERE expires_at IS NOT NULL AND expires_at < ?
|
|
44
|
+
`,
|
|
45
|
+
).run(now.toISOString());
|
|
46
|
+
|
|
47
|
+
// Get existing record
|
|
48
|
+
const existing = db
|
|
49
|
+
.prepare(
|
|
50
|
+
`
|
|
51
|
+
SELECT * FROM rate_limits
|
|
52
|
+
WHERE identifier = ? AND action = ?
|
|
53
|
+
`,
|
|
54
|
+
)
|
|
55
|
+
.get(identifier, action) as any;
|
|
56
|
+
|
|
57
|
+
if (!existing) {
|
|
58
|
+
// First attempt - create record
|
|
59
|
+
const id = randomBytes(16).toString("hex");
|
|
60
|
+
db.prepare(
|
|
61
|
+
`
|
|
62
|
+
INSERT INTO rate_limits (id, identifier, action, attempts, first_attempt, last_attempt, created_at)
|
|
63
|
+
VALUES (?, ?, ?, 1, ?, ?, ?)
|
|
64
|
+
`,
|
|
65
|
+
).run(
|
|
66
|
+
id,
|
|
67
|
+
identifier,
|
|
68
|
+
action,
|
|
69
|
+
now.toISOString(),
|
|
70
|
+
now.toISOString(),
|
|
71
|
+
now.toISOString(),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
allowed: true,
|
|
76
|
+
remaining: config.maxAttempts - 1,
|
|
77
|
+
resetAt: new Date(now.getTime() + config.windowMs),
|
|
78
|
+
lockedUntil: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check if currently locked
|
|
83
|
+
if (existing.expires_at && new Date(existing.expires_at) > now) {
|
|
84
|
+
return {
|
|
85
|
+
allowed: false,
|
|
86
|
+
remaining: 0,
|
|
87
|
+
resetAt: null,
|
|
88
|
+
lockedUntil: new Date(existing.expires_at),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if within window
|
|
93
|
+
const firstAttempt = existing.first_attempt
|
|
94
|
+
? new Date(existing.first_attempt)
|
|
95
|
+
: now;
|
|
96
|
+
const windowEnd = new Date(firstAttempt.getTime() + config.windowMs);
|
|
97
|
+
|
|
98
|
+
if (now > windowEnd) {
|
|
99
|
+
// Window expired - reset attempts
|
|
100
|
+
db.prepare(
|
|
101
|
+
`
|
|
102
|
+
UPDATE rate_limits
|
|
103
|
+
SET attempts = 1, first_attempt = ?, last_attempt = ?, expires_at = NULL
|
|
104
|
+
WHERE identifier = ? AND action = ?
|
|
105
|
+
`,
|
|
106
|
+
).run(now.toISOString(), now.toISOString(), identifier, action);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
allowed: true,
|
|
110
|
+
remaining: config.maxAttempts - 1,
|
|
111
|
+
resetAt: new Date(now.getTime() + config.windowMs),
|
|
112
|
+
lockedUntil: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Within window - check attempts
|
|
117
|
+
const attempts = existing.attempts || 0;
|
|
118
|
+
const remaining = config.maxAttempts - attempts;
|
|
119
|
+
|
|
120
|
+
if (attempts >= config.maxAttempts) {
|
|
121
|
+
// Lock out the user
|
|
122
|
+
const lockUntil = new Date(now.getTime() + config.lockoutMs);
|
|
123
|
+
db.prepare(
|
|
124
|
+
`
|
|
125
|
+
UPDATE rate_limits
|
|
126
|
+
SET last_attempt = ?, expires_at = ?
|
|
127
|
+
WHERE identifier = ? AND action = ?
|
|
128
|
+
`,
|
|
129
|
+
).run(now.toISOString(), lockUntil.toISOString(), identifier, action);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
allowed: false,
|
|
133
|
+
remaining: 0,
|
|
134
|
+
resetAt: windowEnd,
|
|
135
|
+
lockedUntil: lockUntil,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Increment attempts
|
|
140
|
+
db.prepare(
|
|
141
|
+
`
|
|
142
|
+
UPDATE rate_limits
|
|
143
|
+
SET attempts = attempts + 1, last_attempt = ?
|
|
144
|
+
WHERE identifier = ? AND action = ?
|
|
145
|
+
`,
|
|
146
|
+
).run(now.toISOString(), identifier, action);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
allowed: true,
|
|
150
|
+
remaining: remaining - 1,
|
|
151
|
+
resetAt: windowEnd,
|
|
152
|
+
lockedUntil: null,
|
|
153
|
+
};
|
|
154
|
+
} finally {
|
|
155
|
+
db.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function resetRateLimit(
|
|
160
|
+
identifier: string,
|
|
161
|
+
action: string = "login",
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const db = getDb();
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
db.prepare(
|
|
167
|
+
`
|
|
168
|
+
DELETE FROM rate_limits WHERE identifier = ? AND action = ?
|
|
169
|
+
`,
|
|
170
|
+
).run(identifier, action);
|
|
171
|
+
} finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getAccountLockStatus(email: string): Promise<{
|
|
177
|
+
isLocked: boolean;
|
|
178
|
+
lockedUntil: Date | null;
|
|
179
|
+
failedAttempts: number;
|
|
180
|
+
}> {
|
|
181
|
+
const db = getDb();
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const user = db
|
|
185
|
+
.prepare(
|
|
186
|
+
`
|
|
187
|
+
SELECT locked, locked_until, failed_login_attempts
|
|
188
|
+
FROM users
|
|
189
|
+
WHERE LOWER(email) = LOWER(?)
|
|
190
|
+
`,
|
|
191
|
+
)
|
|
192
|
+
.get(email) as any;
|
|
193
|
+
|
|
194
|
+
if (!user) {
|
|
195
|
+
return { isLocked: false, lockedUntil: null, failedAttempts: 0 };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const now = new Date();
|
|
199
|
+
const lockedUntil = user.locked_until ? new Date(user.locked_until) : null;
|
|
200
|
+
const isLocked = user.locked === 1 && (!lockedUntil || lockedUntil > now);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
isLocked,
|
|
204
|
+
lockedUntil: isLocked ? lockedUntil : null,
|
|
205
|
+
failedAttempts: user.failed_login_attempts || 0,
|
|
206
|
+
};
|
|
207
|
+
} finally {
|
|
208
|
+
db.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function recordFailedLogin(email: string): Promise<void> {
|
|
213
|
+
const db = getDb();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const now = new Date();
|
|
217
|
+
const user = db
|
|
218
|
+
.prepare(
|
|
219
|
+
`
|
|
220
|
+
SELECT id FROM users WHERE LOWER(email) = LOWER(?)
|
|
221
|
+
`,
|
|
222
|
+
)
|
|
223
|
+
.get(email) as any;
|
|
224
|
+
|
|
225
|
+
if (!user) return;
|
|
226
|
+
|
|
227
|
+
// Increment failed attempts
|
|
228
|
+
db.prepare(
|
|
229
|
+
`
|
|
230
|
+
UPDATE users
|
|
231
|
+
SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1,
|
|
232
|
+
last_login = ?,
|
|
233
|
+
locked = CASE
|
|
234
|
+
WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN 1
|
|
235
|
+
ELSE 0
|
|
236
|
+
END,
|
|
237
|
+
locked_until = CASE
|
|
238
|
+
WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN ?
|
|
239
|
+
ELSE NULL
|
|
240
|
+
END
|
|
241
|
+
WHERE id = ?
|
|
242
|
+
`,
|
|
243
|
+
).run(
|
|
244
|
+
now.toISOString(),
|
|
245
|
+
new Date(now.getTime() + 15 * 60 * 1000).toISOString(),
|
|
246
|
+
user.id,
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
db.close();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function unlockAccount(email: string): Promise<void> {
|
|
254
|
+
const db = getDb();
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
db.prepare(
|
|
258
|
+
`
|
|
259
|
+
UPDATE users
|
|
260
|
+
SET locked = 0, locked_until = NULL, failed_login_attempts = 0
|
|
261
|
+
WHERE LOWER(email) = LOWER(?)
|
|
262
|
+
`,
|
|
263
|
+
).run(email);
|
|
264
|
+
} finally {
|
|
265
|
+
db.close();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { getDatabaseConfig } from "@/lib/db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract the Public Dev URL ID from either a full URL or just the ID.
|
|
6
|
+
* Handles formats like:
|
|
7
|
+
* - https://bucket.pub-xxx.r2.dev -> pub-xxx
|
|
8
|
+
* - pub-xxx -> pub-xxx
|
|
9
|
+
* - empty/undefined -> ""
|
|
10
|
+
*/
|
|
11
|
+
function extractPublicDevUrlId(url?: string): string {
|
|
12
|
+
if (!url) return "";
|
|
13
|
+
if (url.startsWith("pub-")) return url; // Already just the ID
|
|
14
|
+
|
|
15
|
+
// Extract from URL like https://bucket.pub-xxx.r2.dev
|
|
16
|
+
const match = url.match(/pub-[a-zA-Z0-9]+/i);
|
|
17
|
+
return match ? match[0] : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StorageProviderType =
|
|
21
|
+
| "local"
|
|
22
|
+
| "aws"
|
|
23
|
+
| "r2"
|
|
24
|
+
| "gcs"
|
|
25
|
+
| "digitalocean"
|
|
26
|
+
| "backblaze"
|
|
27
|
+
| "wasabi"
|
|
28
|
+
| "bunny"
|
|
29
|
+
| "cloudinary"
|
|
30
|
+
| "imgix"
|
|
31
|
+
| "ftp";
|
|
32
|
+
|
|
33
|
+
export interface StorageConfig {
|
|
34
|
+
/** The storage provider type */
|
|
35
|
+
provider: StorageProviderType;
|
|
36
|
+
/** The base URL for accessing files */
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
/** The filesystem directory (for local storage only) */
|
|
39
|
+
uploadDir?: string;
|
|
40
|
+
/** Provider-specific config */
|
|
41
|
+
config: {
|
|
42
|
+
bucket?: string;
|
|
43
|
+
region?: string;
|
|
44
|
+
accountId?: string;
|
|
45
|
+
accessKeyId?: string;
|
|
46
|
+
secretAccessKey?: string;
|
|
47
|
+
cdnUrl?: string;
|
|
48
|
+
prefix?: string;
|
|
49
|
+
publicDevUrl?: string;
|
|
50
|
+
cloudName?: string;
|
|
51
|
+
apiKey?: string;
|
|
52
|
+
apiSecret?: string;
|
|
53
|
+
folder?: string;
|
|
54
|
+
uploadPreset?: string;
|
|
55
|
+
domain?: string;
|
|
56
|
+
signKey?: string;
|
|
57
|
+
host?: string;
|
|
58
|
+
port?: number;
|
|
59
|
+
user?: string;
|
|
60
|
+
password?: string;
|
|
61
|
+
secure?: boolean;
|
|
62
|
+
storageZone?: string;
|
|
63
|
+
projectId?: string;
|
|
64
|
+
type?: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RawStorageSettings {
|
|
69
|
+
provider?: string;
|
|
70
|
+
local?: {
|
|
71
|
+
uploadDir?: string;
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
};
|
|
74
|
+
aws?: Record<string, any>;
|
|
75
|
+
r2?: Record<string, any>;
|
|
76
|
+
gcs?: Record<string, any>;
|
|
77
|
+
digitalocean?: Record<string, any>;
|
|
78
|
+
backblaze?: Record<string, any>;
|
|
79
|
+
wasabi?: Record<string, any>;
|
|
80
|
+
bunny?: Record<string, any>;
|
|
81
|
+
cloudinary?: Record<string, any>;
|
|
82
|
+
imgix?: Record<string, any>;
|
|
83
|
+
ftp?: Record<string, any>;
|
|
84
|
+
limits?: Record<string, any>;
|
|
85
|
+
[key: string]: any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the complete storage configuration from the globals table.
|
|
90
|
+
*/
|
|
91
|
+
export async function getStorageConfig(): Promise<StorageConfig> {
|
|
92
|
+
const dbConfig = getDatabaseConfig();
|
|
93
|
+
let rawSettings: RawStorageSettings = { provider: "local" };
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (dbConfig.type === "sqlite") {
|
|
97
|
+
const Database = (await import("better-sqlite3")).default;
|
|
98
|
+
const db = new Database(dbConfig.contentDbPath || "./data/content.db");
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const row = db
|
|
102
|
+
.prepare("SELECT data FROM globals WHERE slug = ?")
|
|
103
|
+
.get("storage-settings") as { data: string } | undefined;
|
|
104
|
+
|
|
105
|
+
if (row) {
|
|
106
|
+
rawSettings =
|
|
107
|
+
typeof row.data === "string" ? JSON.parse(row.data) : row.data;
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Use defaults if anything fails
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const provider = (rawSettings.provider || "local") as StorageProviderType;
|
|
118
|
+
|
|
119
|
+
// Build config based on provider type
|
|
120
|
+
const config: StorageConfig = {
|
|
121
|
+
provider,
|
|
122
|
+
baseUrl: "",
|
|
123
|
+
config: {},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
switch (provider) {
|
|
127
|
+
case "local": {
|
|
128
|
+
const localConfig = rawSettings.local || {};
|
|
129
|
+
const uploadDirRaw = localConfig.uploadDir || "./public/uploads";
|
|
130
|
+
const baseUrlRaw = localConfig.baseUrl || "/uploads";
|
|
131
|
+
|
|
132
|
+
// Resolve uploadDir
|
|
133
|
+
let uploadDir: string;
|
|
134
|
+
if (path.isAbsolute(uploadDirRaw)) {
|
|
135
|
+
uploadDir = uploadDirRaw;
|
|
136
|
+
} else if (uploadDirRaw.includes("/") || uploadDirRaw.includes("\\")) {
|
|
137
|
+
uploadDir = path.resolve(process.cwd(), uploadDirRaw);
|
|
138
|
+
} else {
|
|
139
|
+
uploadDir = path.join(process.cwd(), "public", uploadDirRaw);
|
|
140
|
+
}
|
|
141
|
+
config.uploadDir = uploadDir;
|
|
142
|
+
|
|
143
|
+
// Resolve baseUrl
|
|
144
|
+
config.baseUrl = baseUrlRaw.startsWith("/")
|
|
145
|
+
? baseUrlRaw
|
|
146
|
+
: `/${baseUrlRaw}`;
|
|
147
|
+
if (config.baseUrl.length > 1) {
|
|
148
|
+
config.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case "aws": {
|
|
154
|
+
const awsConfig = rawSettings.aws || {};
|
|
155
|
+
config.baseUrl =
|
|
156
|
+
awsConfig.cdnUrl ||
|
|
157
|
+
`https://${awsConfig.bucket}.s3.${awsConfig.region || "us-east-1"}.amazonaws.com`;
|
|
158
|
+
config.config = {
|
|
159
|
+
bucket: awsConfig.bucket,
|
|
160
|
+
region: awsConfig.region || "us-east-1",
|
|
161
|
+
accessKeyId: awsConfig.accessKeyId,
|
|
162
|
+
secretAccessKey: awsConfig.secretAccessKey,
|
|
163
|
+
cdnUrl: awsConfig.cdnUrl,
|
|
164
|
+
prefix: awsConfig.prefix,
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case "r2": {
|
|
170
|
+
const r2Config = rawSettings.r2 || {};
|
|
171
|
+
// Priority: cdnUrl > publicDevUrl > accountId-based URL
|
|
172
|
+
let baseUrl: string;
|
|
173
|
+
if (r2Config.cdnUrl) {
|
|
174
|
+
baseUrl = r2Config.cdnUrl.replace(/\/$/, "");
|
|
175
|
+
} else if (r2Config.publicDevUrl) {
|
|
176
|
+
// Handle both full URL and just the ID
|
|
177
|
+
const pubId = extractPublicDevUrlId(r2Config.publicDevUrl);
|
|
178
|
+
baseUrl = pubId ? `https://${pubId}.r2.dev` : "";
|
|
179
|
+
} else if (r2Config.accountId) {
|
|
180
|
+
baseUrl = `https://${r2Config.bucket}.${r2Config.accountId}.r2.cloudflarestorage.com`;
|
|
181
|
+
} else {
|
|
182
|
+
baseUrl = "";
|
|
183
|
+
}
|
|
184
|
+
config.baseUrl = baseUrl;
|
|
185
|
+
config.config = {
|
|
186
|
+
bucket: r2Config.bucket,
|
|
187
|
+
accountId: r2Config.accountId,
|
|
188
|
+
publicDevUrl: extractPublicDevUrlId(r2Config.publicDevUrl),
|
|
189
|
+
accessKeyId: r2Config.accessKeyId,
|
|
190
|
+
secretAccessKey: r2Config.secretAccessKey,
|
|
191
|
+
cdnUrl: r2Config.cdnUrl,
|
|
192
|
+
prefix: r2Config.prefix,
|
|
193
|
+
};
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "gcs": {
|
|
198
|
+
const gcsConfig = rawSettings.gcs || {};
|
|
199
|
+
config.baseUrl =
|
|
200
|
+
gcsConfig.cdnUrl ||
|
|
201
|
+
`https://storage.googleapis.com/${gcsConfig.bucket}`;
|
|
202
|
+
config.config = {
|
|
203
|
+
bucket: gcsConfig.bucket,
|
|
204
|
+
projectId: gcsConfig.projectId,
|
|
205
|
+
accessKeyId: gcsConfig.clientEmail,
|
|
206
|
+
secretAccessKey: gcsConfig.privateKey,
|
|
207
|
+
cdnUrl: gcsConfig.cdnUrl,
|
|
208
|
+
prefix: gcsConfig.prefix,
|
|
209
|
+
};
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "digitalocean": {
|
|
214
|
+
const doConfig = rawSettings.digitalocean || {};
|
|
215
|
+
const region = doConfig.region || "nyc3";
|
|
216
|
+
config.baseUrl =
|
|
217
|
+
doConfig.cdnUrl ||
|
|
218
|
+
`https://${doConfig.bucket}.${region}.cdn.digitaloceanspaces.com`;
|
|
219
|
+
config.config = {
|
|
220
|
+
bucket: doConfig.bucket,
|
|
221
|
+
region,
|
|
222
|
+
accessKeyId: doConfig.accessKeyId,
|
|
223
|
+
secretAccessKey: doConfig.secretAccessKey,
|
|
224
|
+
cdnUrl: doConfig.cdnUrl,
|
|
225
|
+
prefix: doConfig.prefix,
|
|
226
|
+
};
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case "backblaze": {
|
|
231
|
+
const b2Config = rawSettings.backblaze || {};
|
|
232
|
+
config.baseUrl = b2Config.cdnUrl || `https://f000.backblazeb2.com`;
|
|
233
|
+
config.config = {
|
|
234
|
+
bucket: b2Config.bucket,
|
|
235
|
+
accountId: b2Config.accountId,
|
|
236
|
+
accessKeyId: b2Config.applicationKeyId,
|
|
237
|
+
secretAccessKey: b2Config.applicationKey,
|
|
238
|
+
cdnUrl: b2Config.cdnUrl,
|
|
239
|
+
prefix: b2Config.prefix,
|
|
240
|
+
};
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "wasabi": {
|
|
245
|
+
const wasabiConfig = rawSettings.wasabi || {};
|
|
246
|
+
const region = wasabiConfig.region || "us-east-1";
|
|
247
|
+
config.baseUrl =
|
|
248
|
+
wasabiConfig.cdnUrl || `https://s3.${region}.wasabisys.com`;
|
|
249
|
+
config.config = {
|
|
250
|
+
bucket: wasabiConfig.bucket,
|
|
251
|
+
region,
|
|
252
|
+
accessKeyId: wasabiConfig.accessKeyId,
|
|
253
|
+
secretAccessKey: wasabiConfig.secretAccessKey,
|
|
254
|
+
cdnUrl: wasabiConfig.cdnUrl,
|
|
255
|
+
prefix: wasabiConfig.prefix,
|
|
256
|
+
};
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "bunny": {
|
|
261
|
+
const bunnyConfig = rawSettings.bunny || {};
|
|
262
|
+
config.baseUrl =
|
|
263
|
+
bunnyConfig.cdnUrl || `https://${bunnyConfig.storageZone}.b-cdn.net`;
|
|
264
|
+
config.config = {
|
|
265
|
+
storageZone: bunnyConfig.storageZone,
|
|
266
|
+
apiKey: bunnyConfig.apiKey,
|
|
267
|
+
cdnUrl: bunnyConfig.cdnUrl,
|
|
268
|
+
prefix: bunnyConfig.prefix,
|
|
269
|
+
};
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "cloudinary": {
|
|
274
|
+
const cloudinaryConfig = rawSettings.cloudinary || {};
|
|
275
|
+
config.baseUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload`;
|
|
276
|
+
config.config = {
|
|
277
|
+
cloudName: cloudinaryConfig.cloudName,
|
|
278
|
+
apiKey: cloudinaryConfig.apiKey,
|
|
279
|
+
apiSecret: cloudinaryConfig.apiSecret,
|
|
280
|
+
folder: cloudinaryConfig.folder,
|
|
281
|
+
uploadPreset: cloudinaryConfig.uploadPreset,
|
|
282
|
+
};
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case "imgix": {
|
|
287
|
+
const imgixConfig = rawSettings.imgix || {};
|
|
288
|
+
config.baseUrl = `https://${imgixConfig.domain}`;
|
|
289
|
+
config.config = {
|
|
290
|
+
domain: imgixConfig.domain,
|
|
291
|
+
signKey: imgixConfig.signKey,
|
|
292
|
+
};
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "ftp": {
|
|
297
|
+
const ftpConfig = rawSettings.ftp || {};
|
|
298
|
+
config.baseUrl = ftpConfig.baseUrl || "";
|
|
299
|
+
config.config = {
|
|
300
|
+
host: ftpConfig.host,
|
|
301
|
+
port: ftpConfig.port || 22,
|
|
302
|
+
user: ftpConfig.user,
|
|
303
|
+
password: ftpConfig.password,
|
|
304
|
+
secure: ftpConfig.secure,
|
|
305
|
+
prefix: ftpConfig.prefix,
|
|
306
|
+
type: "sftp",
|
|
307
|
+
};
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
default:
|
|
312
|
+
// Fallback to local
|
|
313
|
+
config.provider = "local";
|
|
314
|
+
config.uploadDir = path.join(process.cwd(), "public", "uploads");
|
|
315
|
+
config.baseUrl = "/uploads";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return config;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Construct a media URL from filename and optional folder using current storage settings.
|
|
323
|
+
* For cloud providers, this includes the full base URL. For local, it uses relative path.
|
|
324
|
+
*/
|
|
325
|
+
export async function constructMediaUrl(
|
|
326
|
+
filename: string,
|
|
327
|
+
folder?: string | null,
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
const config = await getStorageConfig();
|
|
330
|
+
return constructMediaUrlSync(config.baseUrl, filename, folder);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Construct a media URL synchronously.
|
|
335
|
+
*/
|
|
336
|
+
export function constructMediaUrlSync(
|
|
337
|
+
baseUrl: string,
|
|
338
|
+
filename: string,
|
|
339
|
+
folder?: string | null,
|
|
340
|
+
): string {
|
|
341
|
+
// Cloudinary stores full URL in the database - just return it if baseUrl matches
|
|
342
|
+
// The filename for cloud storage already contains folder path
|
|
343
|
+
if (baseUrl.startsWith("http") && filename.startsWith("http")) {
|
|
344
|
+
// Already a full URL (e.g., from Cloudinary upload response)
|
|
345
|
+
return filename;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Ensure baseUrl has trailing slash for proper URL construction
|
|
349
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
|
350
|
+
const folderPrefix = folder ? `${folder}/` : "";
|
|
351
|
+
|
|
352
|
+
// For local/relative URLs
|
|
353
|
+
return `${normalizedBaseUrl}${folderPrefix}${filename}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get provider display name
|
|
358
|
+
*/
|
|
359
|
+
export function getProviderDisplayName(provider: string): string {
|
|
360
|
+
const names: Record<string, string> = {
|
|
361
|
+
local: "Local Server",
|
|
362
|
+
aws: "AWS S3",
|
|
363
|
+
r2: "Cloudflare R2",
|
|
364
|
+
gcs: "Google Cloud Storage",
|
|
365
|
+
digitalocean: "DigitalOcean Spaces",
|
|
366
|
+
backblaze: "Backblaze B2",
|
|
367
|
+
wasabi: "Wasabi",
|
|
368
|
+
bunny: "Bunny.net",
|
|
369
|
+
cloudinary: "Cloudinary",
|
|
370
|
+
imgix: "Imgix",
|
|
371
|
+
ftp: "FTP",
|
|
372
|
+
};
|
|
373
|
+
return names[provider] || provider;
|
|
374
|
+
}
|
package/src/lib/store.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
interface EditorState {
|
|
4
|
+
editor: any;
|
|
5
|
+
setEditor: (editor: any) => void;
|
|
6
|
+
|
|
7
|
+
blockDrawerOpen: boolean;
|
|
8
|
+
openBlockDrawer: (options?: { targetColumn?: number; targetNodePos?: number }) => void;
|
|
9
|
+
closeBlockDrawer: () => void;
|
|
10
|
+
toggleBlockDrawer: () => void;
|
|
11
|
+
|
|
12
|
+
selectedBlock: string | null;
|
|
13
|
+
setSelectedBlock: (block: string | null) => void;
|
|
14
|
+
|
|
15
|
+
pendingInsertPos: number | null;
|
|
16
|
+
pendingTargetColumn: number | null;
|
|
17
|
+
pendingTargetStack: number | null;
|
|
18
|
+
pendingTargetGroup: number | null;
|
|
19
|
+
pendingTargetCard: number | null;
|
|
20
|
+
pendingTargetRepeater: number | null;
|
|
21
|
+
setPendingInsert: (pos: number | null, column?: number | null) => void;
|
|
22
|
+
clearPendingTargets: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useEditorStore = create<EditorState>((set) => ({
|
|
26
|
+
editor: null,
|
|
27
|
+
setEditor: (editor) => set({ editor }),
|
|
28
|
+
|
|
29
|
+
blockDrawerOpen: false,
|
|
30
|
+
openBlockDrawer: (options) => set({
|
|
31
|
+
blockDrawerOpen: true,
|
|
32
|
+
pendingTargetColumn: options?.targetColumn ?? null
|
|
33
|
+
}),
|
|
34
|
+
closeBlockDrawer: () => set({
|
|
35
|
+
blockDrawerOpen: false,
|
|
36
|
+
pendingTargetColumn: null,
|
|
37
|
+
pendingInsertPos: null,
|
|
38
|
+
pendingTargetStack: null,
|
|
39
|
+
pendingTargetGroup: null,
|
|
40
|
+
pendingTargetCard: null,
|
|
41
|
+
pendingTargetRepeater: null,
|
|
42
|
+
}),
|
|
43
|
+
toggleBlockDrawer: () => set((state) => ({ blockDrawerOpen: !state.blockDrawerOpen })),
|
|
44
|
+
|
|
45
|
+
selectedBlock: null,
|
|
46
|
+
setSelectedBlock: (block) => set({ selectedBlock: block }),
|
|
47
|
+
|
|
48
|
+
pendingInsertPos: null,
|
|
49
|
+
pendingTargetColumn: null,
|
|
50
|
+
pendingTargetStack: null,
|
|
51
|
+
pendingTargetGroup: null,
|
|
52
|
+
pendingTargetCard: null,
|
|
53
|
+
pendingTargetRepeater: null,
|
|
54
|
+
setPendingInsert: (pos, column) => set({
|
|
55
|
+
pendingInsertPos: pos,
|
|
56
|
+
pendingTargetColumn: column ?? null
|
|
57
|
+
}),
|
|
58
|
+
clearPendingTargets: () => set({
|
|
59
|
+
pendingTargetColumn: null,
|
|
60
|
+
pendingTargetStack: null,
|
|
61
|
+
pendingTargetGroup: null,
|
|
62
|
+
pendingTargetCard: null,
|
|
63
|
+
pendingTargetRepeater: null,
|
|
64
|
+
}),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
interface UIState {
|
|
68
|
+
sidebarOpen: boolean;
|
|
69
|
+
toggleSidebar: () => void;
|
|
70
|
+
setSidebarOpen: (open: boolean) => void;
|
|
71
|
+
|
|
72
|
+
activeModal: string | null;
|
|
73
|
+
openModal: (modal: string) => void;
|
|
74
|
+
closeModal: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const useUIStore = create<UIState>((set) => ({
|
|
78
|
+
sidebarOpen: true,
|
|
79
|
+
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
80
|
+
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
|
81
|
+
|
|
82
|
+
activeModal: null,
|
|
83
|
+
openModal: (modal) => set({ activeModal: modal }),
|
|
84
|
+
closeModal: () => set({ activeModal: null }),
|
|
85
|
+
}));
|