@kidecms/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- package/virtual.d.ts +61 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { folders } from "virtual:kide/runtime";
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
7
|
+
const parent = url.searchParams.get("parent");
|
|
8
|
+
if (parent !== null) {
|
|
9
|
+
const items = await folders.findByParent(parent || null);
|
|
10
|
+
return Response.json(items);
|
|
11
|
+
}
|
|
12
|
+
const items = await folders.findAll();
|
|
13
|
+
return Response.json(items);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
17
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
18
|
+
|
|
19
|
+
if (contentType.includes("application/json")) {
|
|
20
|
+
const body = await request.json();
|
|
21
|
+
const { action, id, name, parent } = body;
|
|
22
|
+
|
|
23
|
+
if (action === "create" && name) {
|
|
24
|
+
const folder = await folders.create(String(name), parent ?? null);
|
|
25
|
+
return Response.json(folder, { status: 201 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (action === "rename" && id && name) {
|
|
29
|
+
const folder = await folders.rename(String(id), String(name));
|
|
30
|
+
if (!folder) return Response.json({ error: "Not found." }, { status: 404 });
|
|
31
|
+
return Response.json(folder);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (action === "delete" && id) {
|
|
35
|
+
await folders.delete(String(id));
|
|
36
|
+
return new Response(null, { status: 204 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Response.json({ error: "Invalid action." }, { status: 400 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Form submission (for non-JS fallback / redirect flows)
|
|
43
|
+
const formData = new URLSearchParams(await request.text());
|
|
44
|
+
const action = formData.get("_action");
|
|
45
|
+
const redirectTo = formData.get("redirectTo") || "/admin/assets";
|
|
46
|
+
|
|
47
|
+
if (action === "create") {
|
|
48
|
+
const name = formData.get("name");
|
|
49
|
+
const parent = formData.get("parent");
|
|
50
|
+
if (!name) return Response.json({ error: "Name is required." }, { status: 400 });
|
|
51
|
+
const folder = await folders.create(name, parent || null);
|
|
52
|
+
const folderParam = encodeURIComponent(folder._id);
|
|
53
|
+
return new Response(null, {
|
|
54
|
+
status: 303,
|
|
55
|
+
headers: { Location: `${redirectTo}?folder=${folderParam}&_toast=success&_msg=Folder+created` },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (action === "rename") {
|
|
60
|
+
const id = formData.get("id");
|
|
61
|
+
const name = formData.get("name");
|
|
62
|
+
if (!id || !name) return Response.json({ error: "ID and name required." }, { status: 400 });
|
|
63
|
+
await folders.rename(id, name);
|
|
64
|
+
return new Response(null, {
|
|
65
|
+
status: 303,
|
|
66
|
+
headers: { Location: `${redirectTo}?_toast=success&_msg=Folder+renamed` },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (action === "delete") {
|
|
71
|
+
const id = formData.get("id");
|
|
72
|
+
if (!id) return Response.json({ error: "ID required." }, { status: 400 });
|
|
73
|
+
await folders.delete(id);
|
|
74
|
+
return new Response(null, {
|
|
75
|
+
status: 303,
|
|
76
|
+
headers: { Location: `${redirectTo}?_toast=success&_msg=Folder+deleted` },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Response.json({ error: "Invalid action." }, { status: 400 });
|
|
81
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { assets } from "virtual:kide/runtime";
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ url }) => {
|
|
7
|
+
const lookupUrl = url.searchParams.get("url");
|
|
8
|
+
if (lookupUrl) {
|
|
9
|
+
const asset = await assets.findByUrl(lookupUrl);
|
|
10
|
+
if (!asset) return Response.json(null, { status: 404 });
|
|
11
|
+
return Response.json(asset);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const limit = url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50;
|
|
15
|
+
const offset = url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : 0;
|
|
16
|
+
const folderParam = url.searchParams.get("folder");
|
|
17
|
+
const folder = folderParam !== null ? (folderParam === "" ? null : folderParam) : undefined;
|
|
18
|
+
|
|
19
|
+
const items = await assets.find({ limit, offset, folder });
|
|
20
|
+
const total = await assets.count();
|
|
21
|
+
|
|
22
|
+
return Response.json({ items, total });
|
|
23
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { assets } from "virtual:kide/runtime";
|
|
3
|
+
import config from "virtual:kide/config";
|
|
4
|
+
|
|
5
|
+
export const prerender = false;
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ALLOWED_TYPES = [
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/png",
|
|
10
|
+
"image/gif",
|
|
11
|
+
"image/webp",
|
|
12
|
+
"image/avif",
|
|
13
|
+
"image/svg+xml",
|
|
14
|
+
"application/pdf",
|
|
15
|
+
"video/mp4",
|
|
16
|
+
"video/webm",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const ALLOWED_TYPES = new Set(config.admin?.uploads?.allowedTypes ?? DEFAULT_ALLOWED_TYPES);
|
|
20
|
+
const MAX_FILE_SIZE = config.admin?.uploads?.maxFileSize ?? 50 * 1024 * 1024; // 50 MB
|
|
21
|
+
|
|
22
|
+
// Magic number signatures for binary file type verification
|
|
23
|
+
const MAGIC_SIGNATURES: Array<{ type: string; bytes: number[]; offset?: number }> = [
|
|
24
|
+
{ type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
|
|
25
|
+
{ type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47] },
|
|
26
|
+
{ type: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
27
|
+
{ type: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF....WEBP
|
|
28
|
+
{ type: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] },
|
|
29
|
+
{ type: "video/mp4", bytes: [0x66, 0x74, 0x79, 0x70], offset: 4 }, // ....ftyp
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function verifyMagicBytes(buffer: ArrayBuffer, declaredType: string): boolean {
|
|
33
|
+
// SVG and text-based formats can't be verified by magic bytes
|
|
34
|
+
if (declaredType === "image/svg+xml") {
|
|
35
|
+
const text = new TextDecoder().decode(buffer.slice(0, 256));
|
|
36
|
+
return text.includes("<svg") || text.trimStart().startsWith("<?xml");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const header = new Uint8Array(buffer.slice(0, 16));
|
|
40
|
+
for (const sig of MAGIC_SIGNATURES) {
|
|
41
|
+
if (sig.type !== declaredType) continue;
|
|
42
|
+
const offset = sig.offset ?? 0;
|
|
43
|
+
const match = sig.bytes.every((byte, i) => header[offset + i] === byte);
|
|
44
|
+
if (match) return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// AVIF is an ISOBMFF container — check for ftyp box with avif brand
|
|
48
|
+
if (declaredType === "image/avif") {
|
|
49
|
+
const offset4 = new Uint8Array(buffer.slice(4, 12));
|
|
50
|
+
const ftypStr = String.fromCharCode(...offset4);
|
|
51
|
+
return ftypStr.startsWith("ftyp") && ftypStr.includes("avif");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// video/webm starts with EBML header
|
|
55
|
+
if (declaredType === "video/webm") {
|
|
56
|
+
return header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
63
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
64
|
+
|
|
65
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
66
|
+
return Response.json({ error: "Expected multipart/form-data." }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const formData = await request.formData();
|
|
70
|
+
const file = formData.get("file");
|
|
71
|
+
const alt = formData.get("alt");
|
|
72
|
+
const folder = formData.get("folder");
|
|
73
|
+
|
|
74
|
+
if (!file || !(file instanceof File)) {
|
|
75
|
+
return Response.json({ error: "No file provided." }, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!ALLOWED_TYPES.has(file.type)) {
|
|
79
|
+
return Response.json({ error: `File type "${file.type}" is not allowed.` }, { status: 400 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
83
|
+
return Response.json({ error: `File exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB size limit.` }, { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verify file content matches declared type
|
|
87
|
+
const buffer = await file.arrayBuffer();
|
|
88
|
+
if (!verifyMagicBytes(buffer, file.type)) {
|
|
89
|
+
return Response.json({ error: "File content does not match declared type." }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Reconstruct File from buffer since arrayBuffer() consumed the stream
|
|
93
|
+
const verifiedFile = new File([buffer], file.name, { type: file.type });
|
|
94
|
+
|
|
95
|
+
const asset = await assets.upload(verifiedFile, {
|
|
96
|
+
alt: alt ? String(alt) : undefined,
|
|
97
|
+
folder: folder ? String(folder) : undefined,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const redirectTo = formData.get("redirectTo");
|
|
101
|
+
|
|
102
|
+
if (redirectTo) {
|
|
103
|
+
// Delay so Vite's dev server picks up the new file before the redirect
|
|
104
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
105
|
+
return new Response(null, {
|
|
106
|
+
status: 303,
|
|
107
|
+
headers: { Location: `/admin/assets/${asset._id}?_toast=success&_msg=Asset+uploaded` },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Response.json(asset, { status: 201 });
|
|
112
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { getDb } from "virtual:kide/db";
|
|
5
|
+
import {
|
|
6
|
+
createInvite,
|
|
7
|
+
validateInvite,
|
|
8
|
+
consumeInvite,
|
|
9
|
+
hashPassword,
|
|
10
|
+
createSession,
|
|
11
|
+
setSessionCookie,
|
|
12
|
+
getSessionUser,
|
|
13
|
+
} from "virtual:kide/runtime";
|
|
14
|
+
import { sendInviteEmail, isEmailConfigured } from "virtual:kide/email";
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
export const POST: APIRoute = async ({ request, url }) => {
|
|
19
|
+
const formData = await request.formData();
|
|
20
|
+
const action = String(formData.get("_action") ?? "create");
|
|
21
|
+
|
|
22
|
+
if (action === "accept") {
|
|
23
|
+
return handleAccept(formData);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return handleCreate(formData, url, request);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function handleCreate(formData: FormData, url: URL, request: Request) {
|
|
30
|
+
const user = await getSessionUser(request);
|
|
31
|
+
if (!user || user.role !== "admin") {
|
|
32
|
+
return new Response(null, {
|
|
33
|
+
status: 303,
|
|
34
|
+
headers: { Location: "/admin/users?_toast=error&_msg=Only+admins+can+invite+users" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const email = String(formData.get("email") ?? "").trim();
|
|
39
|
+
const role = String(formData.get("role") ?? "editor");
|
|
40
|
+
const name = String(formData.get("name") ?? email.split("@")[0]).trim();
|
|
41
|
+
|
|
42
|
+
if (!email) {
|
|
43
|
+
return new Response(null, {
|
|
44
|
+
status: 303,
|
|
45
|
+
headers: { Location: "/admin/users/new?_toast=error&_msg=Email+is+required" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const db = await getDb();
|
|
50
|
+
const schema = await import("virtual:kide/schema");
|
|
51
|
+
const tables = schema.cmsTables as Record<string, { main: any }>;
|
|
52
|
+
if (!tables.users) {
|
|
53
|
+
return new Response(null, {
|
|
54
|
+
status: 303,
|
|
55
|
+
headers: { Location: "/admin/users?_toast=error&_msg=Users+collection+not+configured" },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check for duplicate email
|
|
60
|
+
const existing = await db.select().from(tables.users.main).where(eq(tables.users.main.email, email)).limit(1);
|
|
61
|
+
if (existing.length > 0) {
|
|
62
|
+
return new Response(null, {
|
|
63
|
+
status: 303,
|
|
64
|
+
headers: { Location: "/admin/users/new?_toast=error&_msg=A+user+with+this+email+already+exists" },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { nanoid } = await import("nanoid");
|
|
69
|
+
const id = nanoid();
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
|
|
72
|
+
await db.insert(tables.users.main).values({
|
|
73
|
+
_id: id,
|
|
74
|
+
name,
|
|
75
|
+
email,
|
|
76
|
+
password: "",
|
|
77
|
+
role,
|
|
78
|
+
_createdAt: now,
|
|
79
|
+
_updatedAt: now,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const invite = await createInvite(id);
|
|
83
|
+
const inviteUrl = `${url.origin}/admin/invite?token=${invite.token}`;
|
|
84
|
+
|
|
85
|
+
let emailSent = false;
|
|
86
|
+
if (isEmailConfigured()) {
|
|
87
|
+
emailSent = await sendInviteEmail(email, inviteUrl);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const params = new URLSearchParams({
|
|
91
|
+
_toast: "success",
|
|
92
|
+
_msg: emailSent ? `Invitation sent to ${email}` : "User created",
|
|
93
|
+
inviteToken: invite.token,
|
|
94
|
+
emailSent: String(emailSent),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return new Response(null, {
|
|
98
|
+
status: 303,
|
|
99
|
+
headers: { Location: `/admin/users/${id}?${params}` },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleAccept(formData: FormData) {
|
|
104
|
+
const token = String(formData.get("token") ?? "");
|
|
105
|
+
const name = String(formData.get("name") ?? "").trim();
|
|
106
|
+
const password = String(formData.get("password") ?? "");
|
|
107
|
+
const confirmPassword = String(formData.get("confirmPassword") ?? "");
|
|
108
|
+
|
|
109
|
+
if (!token) {
|
|
110
|
+
return new Response(null, {
|
|
111
|
+
status: 303,
|
|
112
|
+
headers: { Location: "/admin/invite?error=invalid" },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!name || !password) {
|
|
117
|
+
return new Response(null, {
|
|
118
|
+
status: 303,
|
|
119
|
+
headers: { Location: `/admin/invite?token=${token}&error=missing` },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (password !== confirmPassword) {
|
|
124
|
+
return new Response(null, {
|
|
125
|
+
status: 303,
|
|
126
|
+
headers: { Location: `/admin/invite?token=${token}&error=password` },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (password.length < 8) {
|
|
131
|
+
return new Response(null, {
|
|
132
|
+
status: 303,
|
|
133
|
+
headers: { Location: `/admin/invite?token=${token}&error=short` },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const invite = await validateInvite(token);
|
|
138
|
+
if (!invite) {
|
|
139
|
+
return new Response(null, {
|
|
140
|
+
status: 303,
|
|
141
|
+
headers: { Location: "/admin/invite?error=expired" },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const db = await getDb();
|
|
146
|
+
const schema = await import("virtual:kide/schema");
|
|
147
|
+
const tables = schema.cmsTables as Record<string, { main: any }>;
|
|
148
|
+
|
|
149
|
+
const hashedPassword = await hashPassword(password);
|
|
150
|
+
await db
|
|
151
|
+
.update(tables.users.main)
|
|
152
|
+
.set({ name, password: hashedPassword, _updatedAt: new Date().toISOString() })
|
|
153
|
+
.where(eq(tables.users.main._id, invite.userId));
|
|
154
|
+
|
|
155
|
+
await consumeInvite(token);
|
|
156
|
+
|
|
157
|
+
const session = await createSession(invite.userId);
|
|
158
|
+
|
|
159
|
+
return new Response(null, {
|
|
160
|
+
status: 303,
|
|
161
|
+
headers: {
|
|
162
|
+
Location: "/admin",
|
|
163
|
+
"Set-Cookie": setSessionCookie(session.token, session.expiresAt),
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import { getDb } from "virtual:kide/db";
|
|
5
|
+
import { verifyPassword, createSession, setSessionCookie } from "virtual:kide/runtime";
|
|
6
|
+
import config from "virtual:kide/config";
|
|
7
|
+
|
|
8
|
+
export const prerender = false;
|
|
9
|
+
|
|
10
|
+
// Simple in-memory rate limiter
|
|
11
|
+
const attempts = new Map<string, { count: number; resetAt: number }>();
|
|
12
|
+
const MAX_ATTEMPTS = config.admin?.rateLimit?.maxAttempts ?? 5;
|
|
13
|
+
const WINDOW_MS = config.admin?.rateLimit?.windowMs ?? 15 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function isRateLimited(ip: string): boolean {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const entry = attempts.get(ip);
|
|
18
|
+
if (!entry || now > entry.resetAt) {
|
|
19
|
+
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
entry.count++;
|
|
23
|
+
return entry.count > MAX_ATTEMPTS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
|
27
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
28
|
+
|
|
29
|
+
if (isRateLimited(clientAddress)) {
|
|
30
|
+
if (contentType.includes("application/json")) {
|
|
31
|
+
return Response.json({ error: "Too many login attempts. Try again later." }, { status: 429 });
|
|
32
|
+
}
|
|
33
|
+
return new Response(null, {
|
|
34
|
+
status: 303,
|
|
35
|
+
headers: { Location: "/admin/login?error=rate-limited" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let email: string;
|
|
40
|
+
let password: string;
|
|
41
|
+
|
|
42
|
+
if (contentType.includes("application/json")) {
|
|
43
|
+
const body = await request.json();
|
|
44
|
+
email = String(body.email ?? "");
|
|
45
|
+
password = String(body.password ?? "");
|
|
46
|
+
} else {
|
|
47
|
+
const formData = await request.formData();
|
|
48
|
+
email = String(formData.get("email") ?? "");
|
|
49
|
+
password = String(formData.get("password") ?? "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!email || !password) {
|
|
53
|
+
if (contentType.includes("application/json")) {
|
|
54
|
+
return Response.json({ error: "Email and password are required." }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
return new Response(null, {
|
|
57
|
+
status: 303,
|
|
58
|
+
headers: { Location: "/admin/login?error=missing" },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const db = await getDb();
|
|
63
|
+
const schema = await import("virtual:kide/schema");
|
|
64
|
+
const tables = schema.cmsTables as Record<string, { main: any }>;
|
|
65
|
+
|
|
66
|
+
if (!tables.users) {
|
|
67
|
+
return Response.json({ error: "Users collection not configured." }, { status: 500 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rows = await db.select().from(tables.users.main).where(eq(tables.users.main.email, email)).limit(1);
|
|
71
|
+
|
|
72
|
+
if (rows.length === 0) {
|
|
73
|
+
if (contentType.includes("application/json")) {
|
|
74
|
+
return Response.json({ error: "Invalid credentials." }, { status: 401 });
|
|
75
|
+
}
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
status: 303,
|
|
78
|
+
headers: { Location: "/admin/login?error=invalid" },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const user = rows[0] as Record<string, unknown>;
|
|
83
|
+
const storedHash = String(user.password ?? "");
|
|
84
|
+
|
|
85
|
+
let valid = false;
|
|
86
|
+
try {
|
|
87
|
+
valid = await verifyPassword(storedHash, password);
|
|
88
|
+
} catch {
|
|
89
|
+
// valid remains false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!valid) {
|
|
93
|
+
if (contentType.includes("application/json")) {
|
|
94
|
+
return Response.json({ error: "Invalid credentials." }, { status: 401 });
|
|
95
|
+
}
|
|
96
|
+
return new Response(null, {
|
|
97
|
+
status: 303,
|
|
98
|
+
headers: { Location: "/admin/login?error=invalid" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Successful login — clear rate limit
|
|
103
|
+
attempts.delete(clientAddress);
|
|
104
|
+
|
|
105
|
+
const session = await createSession(String(user._id));
|
|
106
|
+
|
|
107
|
+
if (contentType.includes("application/json")) {
|
|
108
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"Set-Cookie": setSessionCookie(session.token, session.expiresAt),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new Response(null, {
|
|
118
|
+
status: 303,
|
|
119
|
+
headers: {
|
|
120
|
+
Location: "/admin",
|
|
121
|
+
"Set-Cookie": setSessionCookie(session.token, session.expiresAt),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
import { destroySession, clearSessionCookie } from "virtual:kide/runtime";
|
|
4
|
+
|
|
5
|
+
export const prerender = false;
|
|
6
|
+
|
|
7
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
8
|
+
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
9
|
+
const match = cookieHeader.match(/cms_session=([^;]+)/);
|
|
10
|
+
|
|
11
|
+
if (match) {
|
|
12
|
+
await destroySession(match[1]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
16
|
+
if (contentType.includes("application/json")) {
|
|
17
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"Set-Cookie": clearSessionCookie(),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Response(null, {
|
|
27
|
+
status: 303,
|
|
28
|
+
headers: {
|
|
29
|
+
Location: "/admin/login",
|
|
30
|
+
"Set-Cookie": clearSessionCookie(),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
|
|
4
|
+
import { getDb } from "virtual:kide/db";
|
|
5
|
+
import { hashPassword, createSession, setSessionCookie } from "virtual:kide/runtime";
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
10
|
+
const formData = await request.formData();
|
|
11
|
+
const name = String(formData.get("name") ?? "").trim();
|
|
12
|
+
const email = String(formData.get("email") ?? "").trim();
|
|
13
|
+
const password = String(formData.get("password") ?? "");
|
|
14
|
+
const confirmPassword = String(formData.get("confirmPassword") ?? "");
|
|
15
|
+
|
|
16
|
+
if (!name || !email || !password) {
|
|
17
|
+
return new Response(null, {
|
|
18
|
+
status: 303,
|
|
19
|
+
headers: { Location: "/admin/setup?error=missing" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (password !== confirmPassword) {
|
|
24
|
+
return new Response(null, {
|
|
25
|
+
status: 303,
|
|
26
|
+
headers: { Location: "/admin/setup?error=password" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (password.length < 8) {
|
|
31
|
+
return new Response(null, {
|
|
32
|
+
status: 303,
|
|
33
|
+
headers: { Location: "/admin/setup?error=short" },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const db = await getDb();
|
|
38
|
+
const schema = await import("virtual:kide/schema");
|
|
39
|
+
const tables = schema.cmsTables as Record<string, { main: any }>;
|
|
40
|
+
|
|
41
|
+
if (!tables.users) {
|
|
42
|
+
return Response.json({ error: "Users collection not configured." }, { status: 500 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Prevent setup if users already exist
|
|
46
|
+
const existing = await db.select().from(tables.users.main).limit(1);
|
|
47
|
+
if (existing.length > 0) {
|
|
48
|
+
return new Response(null, {
|
|
49
|
+
status: 303,
|
|
50
|
+
headers: { Location: "/admin/login" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const id = nanoid();
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const hashedPassword = await hashPassword(password);
|
|
57
|
+
|
|
58
|
+
await db.insert(tables.users.main).values({
|
|
59
|
+
_id: id,
|
|
60
|
+
name,
|
|
61
|
+
email,
|
|
62
|
+
password: hashedPassword,
|
|
63
|
+
role: "admin",
|
|
64
|
+
_createdAt: now,
|
|
65
|
+
_updatedAt: now,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const session = await createSession(id);
|
|
69
|
+
|
|
70
|
+
return new Response(null, {
|
|
71
|
+
status: 303,
|
|
72
|
+
headers: {
|
|
73
|
+
Location: "/admin",
|
|
74
|
+
"Set-Cookie": setSessionCookie(session.token, session.expiresAt),
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
import { cms } from "virtual:kide/api";
|
|
4
|
+
|
|
5
|
+
export const prerender = false;
|
|
6
|
+
|
|
7
|
+
const cmsRuntime = cms as Record<string, any> & { scheduled: typeof cms.scheduled };
|
|
8
|
+
|
|
9
|
+
const isAuthorized = (request: Request) => {
|
|
10
|
+
const secret = import.meta.env.CRON_SECRET;
|
|
11
|
+
if (!secret) return true;
|
|
12
|
+
|
|
13
|
+
const authHeader = request.headers.get("authorization") ?? "";
|
|
14
|
+
return authHeader === `Bearer ${secret}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const handler: APIRoute = async ({ request, cache }) => {
|
|
18
|
+
if (!isAuthorized(request)) {
|
|
19
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = await cmsRuntime.scheduled.processPublishing(cache);
|
|
23
|
+
|
|
24
|
+
return Response.json({
|
|
25
|
+
ok: true,
|
|
26
|
+
published: result.published,
|
|
27
|
+
unpublished: result.unpublished,
|
|
28
|
+
processedAt: new Date().toISOString(),
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const GET = handler;
|
|
33
|
+
export const POST = handler;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { transformImage } from "@kidecms/core";
|
|
3
|
+
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async ({ params, url }) => {
|
|
7
|
+
const src = `/${params.path}`;
|
|
8
|
+
const width = url.searchParams.get("w") ? Number(url.searchParams.get("w")) : undefined;
|
|
9
|
+
const format = url.searchParams.get("f") || "webp";
|
|
10
|
+
const quality = url.searchParams.get("q") ? Number(url.searchParams.get("q")) : undefined;
|
|
11
|
+
|
|
12
|
+
const result = await transformImage(src, width, format, quality);
|
|
13
|
+
|
|
14
|
+
if (!result) {
|
|
15
|
+
return new Response("Not found", { status: 404 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Response(new Uint8Array(result.buffer), {
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": result.contentType,
|
|
21
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
};
|