@cancia/astro 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/chunk-22DJVJBR.js +169 -0
- package/dist/chunk-5IPHDIC6.js +19 -0
- package/dist/chunk-7IA5B5CF.js +11 -0
- package/dist/chunk-DGCGIEFD.js +14 -0
- package/dist/chunk-NG5GJME5.js +39 -0
- package/dist/chunk-QAM5VKAF.js +73 -0
- package/dist/chunk-YPVZDWTW.js +34 -0
- package/dist/chunk-YQDZQSES.js +393 -0
- package/dist/endpoints/auth.d.ts +5 -0
- package/dist/endpoints/auth.js +56 -0
- package/dist/endpoints/content.d.ts +11 -0
- package/dist/endpoints/content.js +56 -0
- package/dist/endpoints/health.d.ts +3 -0
- package/dist/endpoints/health.js +9 -0
- package/dist/endpoints/lists.d.ts +14 -0
- package/dist/endpoints/lists.js +36 -0
- package/dist/endpoints/publish.d.ts +5 -0
- package/dist/endpoints/publish.js +34 -0
- package/dist/endpoints/schemas.d.ts +5 -0
- package/dist/endpoints/schemas.js +20 -0
- package/dist/endpoints/upload.d.ts +5 -0
- package/dist/endpoints/upload.js +41 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +712 -0
- package/dist/loader/index.d.ts +26 -0
- package/dist/loader/index.js +7 -0
- package/dist/runtime.d.ts +34 -0
- package/dist/runtime.js +8 -0
- package/dist/schema/index.d.ts +96 -0
- package/dist/schema/index.js +10 -0
- package/dist/types-BMlLS-OS.d.ts +161 -0
- package/dist/upload-DwCGjXbz.d.ts +13 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import {
|
|
2
|
+
makeListsRoutes
|
|
3
|
+
} from "./chunk-22DJVJBR.js";
|
|
4
|
+
import {
|
|
5
|
+
makeSchemasRoute
|
|
6
|
+
} from "./chunk-YPVZDWTW.js";
|
|
7
|
+
import "./chunk-NG5GJME5.js";
|
|
8
|
+
import {
|
|
9
|
+
setCanciaRuntime
|
|
10
|
+
} from "./chunk-DGCGIEFD.js";
|
|
11
|
+
import {
|
|
12
|
+
defineList,
|
|
13
|
+
describeList,
|
|
14
|
+
z
|
|
15
|
+
} from "./chunk-QAM5VKAF.js";
|
|
16
|
+
import {
|
|
17
|
+
canciaLoader,
|
|
18
|
+
createJsonFileAdapter,
|
|
19
|
+
createJsonFileAdapterV2
|
|
20
|
+
} from "./chunk-YQDZQSES.js";
|
|
21
|
+
import {
|
|
22
|
+
RevConflictError
|
|
23
|
+
} from "./chunk-7IA5B5CF.js";
|
|
24
|
+
import {
|
|
25
|
+
detectImageType,
|
|
26
|
+
isValidSite
|
|
27
|
+
} from "./chunk-5IPHDIC6.js";
|
|
28
|
+
|
|
29
|
+
// src/integration.ts
|
|
30
|
+
import { loadEnv } from "vite";
|
|
31
|
+
import { fileURLToPath } from "url";
|
|
32
|
+
|
|
33
|
+
// src/routes/content.ts
|
|
34
|
+
function makeContentRoute(storage, secret) {
|
|
35
|
+
function checkAuth(req) {
|
|
36
|
+
if (!secret) return null;
|
|
37
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "").trim();
|
|
38
|
+
if (token !== secret) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
GET: async ({ request }) => {
|
|
43
|
+
const deny = checkAuth(request);
|
|
44
|
+
if (deny) return deny;
|
|
45
|
+
const site = new URL(request.url).searchParams.get("site");
|
|
46
|
+
if (!site) return new Response(JSON.stringify({ error: "Missing ?site=" }), { status: 400 });
|
|
47
|
+
const data = await storage.getAll(site);
|
|
48
|
+
return new Response(JSON.stringify(data), {
|
|
49
|
+
headers: { "Content-Type": "application/json" }
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
POST: async ({ request }) => {
|
|
53
|
+
const deny = checkAuth(request);
|
|
54
|
+
if (deny) return deny;
|
|
55
|
+
const body = await request.json().catch(() => null);
|
|
56
|
+
if (!body?.site || !body?.key || !body?.lang || body?.value === void 0) {
|
|
57
|
+
return new Response(JSON.stringify({ error: "Missing fields: site, key, lang, value" }), { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
await storage.set(body.site, body.key, body.lang, body.value);
|
|
60
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
61
|
+
headers: { "Content-Type": "application/json" }
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
DELETE: async ({ request }) => {
|
|
65
|
+
const deny = checkAuth(request);
|
|
66
|
+
if (deny) return deny;
|
|
67
|
+
const body = await request.json().catch(() => null);
|
|
68
|
+
if (!body?.site || !body?.key || !body?.lang) {
|
|
69
|
+
return new Response(JSON.stringify({ error: "Missing fields: site, key, lang" }), { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
await storage.delete(body.site, body.key, body.lang);
|
|
72
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
73
|
+
headers: { "Content-Type": "application/json" }
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/routes/upload.ts
|
|
80
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
81
|
+
import { join } from "path";
|
|
82
|
+
import { randomUUID } from "crypto";
|
|
83
|
+
function makeLocalUploadHandler(opts) {
|
|
84
|
+
const uploadDir = opts.uploadDir ?? join(process.cwd(), "public/uploads");
|
|
85
|
+
const publicUrlBase = opts.publicUrlBase ?? "/uploads";
|
|
86
|
+
const maxBytes = (opts.maxMB ?? 10) * 1024 * 1024;
|
|
87
|
+
return async (file, site, detected) => {
|
|
88
|
+
const dest = join(uploadDir, site);
|
|
89
|
+
await mkdir(dest, { recursive: true });
|
|
90
|
+
const name = `${randomUUID()}.${detected.ext}`;
|
|
91
|
+
await writeFile(join(dest, name), Buffer.from(await file.arrayBuffer()));
|
|
92
|
+
return `${publicUrlBase}/${site}/${name}`;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function makeUploadRoute(uploadHandler, secret, maxMB = 10) {
|
|
96
|
+
return async ({ request }) => {
|
|
97
|
+
if (secret) {
|
|
98
|
+
const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
|
|
99
|
+
if (token !== secret) {
|
|
100
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const form = await request.formData().catch(() => null);
|
|
104
|
+
const file = form?.get("file");
|
|
105
|
+
const site = form?.get("site");
|
|
106
|
+
if (!(file instanceof File)) {
|
|
107
|
+
return new Response(JSON.stringify({ error: "Missing file field" }), { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
if (typeof site !== "string" || !isValidSite(site)) {
|
|
110
|
+
return new Response(
|
|
111
|
+
JSON.stringify({ error: "Invalid site (allowed: a-z, 0-9, and . _ -; must start alphanumeric)" }),
|
|
112
|
+
{ status: 400 }
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (file.size > maxMB * 1024 * 1024) {
|
|
116
|
+
return new Response(JSON.stringify({ error: `File too large (max ${maxMB}MB)` }), { status: 413 });
|
|
117
|
+
}
|
|
118
|
+
const buf = new Uint8Array(await file.arrayBuffer());
|
|
119
|
+
const detected = detectImageType(buf);
|
|
120
|
+
if (!detected) {
|
|
121
|
+
return new Response(JSON.stringify({ error: "File type not allowed" }), { status: 415 });
|
|
122
|
+
}
|
|
123
|
+
const url = await uploadHandler(file, site, detected);
|
|
124
|
+
return new Response(JSON.stringify({ url }), {
|
|
125
|
+
headers: { "Content-Type": "application/json" }
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/routes/publish.ts
|
|
131
|
+
function makePublishRoute(deployHook, secret) {
|
|
132
|
+
return async ({ request }) => {
|
|
133
|
+
if (secret) {
|
|
134
|
+
const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
|
|
135
|
+
if (token !== secret) {
|
|
136
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const hook = deployHook ?? process.env.CANCIA_DEPLOY_HOOK;
|
|
140
|
+
if (!hook) {
|
|
141
|
+
return new Response(
|
|
142
|
+
JSON.stringify({ error: "No deploy hook configured. Set CANCIA_DEPLOY_HOOK." }),
|
|
143
|
+
{ status: 503 }
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(hook, { method: "POST" });
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
return new Response(
|
|
150
|
+
JSON.stringify({ error: `Deploy hook responded with ${res.status}` }),
|
|
151
|
+
{ status: 502 }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
155
|
+
headers: { "Content-Type": "application/json" }
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
return new Response(JSON.stringify({ error: "Failed to reach deploy hook" }), { status: 502 });
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/routes/auth.ts
|
|
164
|
+
var buckets = /* @__PURE__ */ new Map();
|
|
165
|
+
var MAX_FAILURES = 5;
|
|
166
|
+
var WINDOW_MS = 6e4;
|
|
167
|
+
function getIp(req) {
|
|
168
|
+
return req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? "unknown";
|
|
169
|
+
}
|
|
170
|
+
function isRateLimited(ip) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const bucket = buckets.get(ip);
|
|
173
|
+
if (!bucket || now > bucket.resetAt) return false;
|
|
174
|
+
return bucket.failures >= MAX_FAILURES;
|
|
175
|
+
}
|
|
176
|
+
function recordFailure(ip) {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const bucket = buckets.get(ip);
|
|
179
|
+
if (!bucket || now > bucket.resetAt) {
|
|
180
|
+
buckets.set(ip, { failures: 1, resetAt: now + WINDOW_MS });
|
|
181
|
+
} else {
|
|
182
|
+
bucket.failures += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function clearFailures(ip) {
|
|
186
|
+
buckets.delete(ip);
|
|
187
|
+
}
|
|
188
|
+
function makeAuthRoute(secret) {
|
|
189
|
+
return async function authRoute(req) {
|
|
190
|
+
const ip = getIp(req);
|
|
191
|
+
if (isRateLimited(ip)) {
|
|
192
|
+
return new Response(
|
|
193
|
+
JSON.stringify({ error: "Too many attempts. Try again in a minute." }),
|
|
194
|
+
{ status: 429, headers: { "Content-Type": "application/json" } }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
let token;
|
|
198
|
+
try {
|
|
199
|
+
const body = await req.json();
|
|
200
|
+
token = body?.token;
|
|
201
|
+
} catch {
|
|
202
|
+
return new Response(
|
|
203
|
+
JSON.stringify({ error: "Invalid request body" }),
|
|
204
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (!token || token !== secret) {
|
|
208
|
+
recordFailure(ip);
|
|
209
|
+
return new Response(
|
|
210
|
+
JSON.stringify({ error: "Invalid token" }),
|
|
211
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
clearFailures(ip);
|
|
215
|
+
return new Response(
|
|
216
|
+
JSON.stringify({ ok: true }),
|
|
217
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/routes/upload-r2.ts
|
|
223
|
+
import { createHmac, createHash } from "crypto";
|
|
224
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
225
|
+
function sha256hex(data) {
|
|
226
|
+
return createHash("sha256").update(data).digest("hex");
|
|
227
|
+
}
|
|
228
|
+
function hmacSha256(key, data) {
|
|
229
|
+
return createHmac("sha256", key).update(data).digest();
|
|
230
|
+
}
|
|
231
|
+
function getSigningKey(secretKey, date, region, service) {
|
|
232
|
+
const kDate = hmacSha256(Buffer.from(`AWS4${secretKey}`, "utf8"), date);
|
|
233
|
+
const kRegion = hmacSha256(kDate, region);
|
|
234
|
+
const kService = hmacSha256(kRegion, service);
|
|
235
|
+
const kSigning = hmacSha256(kService, "aws4_request");
|
|
236
|
+
return kSigning;
|
|
237
|
+
}
|
|
238
|
+
async function signedPutRequest(opts) {
|
|
239
|
+
const { endpoint, bucket, key, body, contentType, accessKeyId, secretAccessKey } = opts;
|
|
240
|
+
const region = "auto";
|
|
241
|
+
const service = "s3";
|
|
242
|
+
const now = /* @__PURE__ */ new Date();
|
|
243
|
+
const isoDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15) + "Z";
|
|
244
|
+
const shortDate = isoDate.slice(0, 8);
|
|
245
|
+
const url = `${endpoint}/${bucket}/${key}`;
|
|
246
|
+
const host = new URL(endpoint).host;
|
|
247
|
+
const payloadHash = sha256hex(body);
|
|
248
|
+
const headers = {
|
|
249
|
+
"content-type": contentType,
|
|
250
|
+
"host": host,
|
|
251
|
+
"x-amz-content-sha256": payloadHash,
|
|
252
|
+
"x-amz-date": isoDate
|
|
253
|
+
};
|
|
254
|
+
const signedHeaders = Object.keys(headers).sort().join(";");
|
|
255
|
+
const canonicalHeaders = Object.keys(headers).sort().map((k) => `${k}:${headers[k]}
|
|
256
|
+
`).join("");
|
|
257
|
+
const canonicalRequest = [
|
|
258
|
+
"PUT",
|
|
259
|
+
`/${bucket}/${key}`,
|
|
260
|
+
"",
|
|
261
|
+
canonicalHeaders,
|
|
262
|
+
signedHeaders,
|
|
263
|
+
payloadHash
|
|
264
|
+
].join("\n");
|
|
265
|
+
const credentialScope = `${shortDate}/${region}/${service}/aws4_request`;
|
|
266
|
+
const stringToSign = [
|
|
267
|
+
"AWS4-HMAC-SHA256",
|
|
268
|
+
isoDate,
|
|
269
|
+
credentialScope,
|
|
270
|
+
sha256hex(canonicalRequest)
|
|
271
|
+
].join("\n");
|
|
272
|
+
const signingKey = getSigningKey(secretAccessKey, shortDate, region, service);
|
|
273
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
274
|
+
const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
275
|
+
let res;
|
|
276
|
+
try {
|
|
277
|
+
res = await fetch(url, {
|
|
278
|
+
method: "PUT",
|
|
279
|
+
headers: { ...headers, Authorization: authHeader },
|
|
280
|
+
body: new Uint8Array(body)
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new Error(`R2 upload: network error reaching ${url} \u2014 ${err}`);
|
|
284
|
+
}
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
const text = await res.text().catch(() => res.statusText);
|
|
287
|
+
throw new Error(`R2 upload failed (${res.status}): ${text}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function makeR2UploadHandler(opts) {
|
|
291
|
+
const {
|
|
292
|
+
accountId,
|
|
293
|
+
bucket,
|
|
294
|
+
accessKeyId,
|
|
295
|
+
secretAccessKey,
|
|
296
|
+
publicUrl,
|
|
297
|
+
prefix = "uploads"
|
|
298
|
+
} = opts;
|
|
299
|
+
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
|
|
300
|
+
return async (file, _site, detected) => {
|
|
301
|
+
const key = `${prefix}/${randomUUID2()}.${detected.ext}`;
|
|
302
|
+
const body = Buffer.from(await file.arrayBuffer());
|
|
303
|
+
await signedPutRequest({
|
|
304
|
+
endpoint,
|
|
305
|
+
bucket,
|
|
306
|
+
key,
|
|
307
|
+
body,
|
|
308
|
+
contentType: file.type,
|
|
309
|
+
accessKeyId,
|
|
310
|
+
secretAccessKey
|
|
311
|
+
});
|
|
312
|
+
return `${publicUrl.replace(/\/$/, "")}/${key}`;
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/token.ts
|
|
317
|
+
import { randomBytes } from "crypto";
|
|
318
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
319
|
+
import { join as join2 } from "path";
|
|
320
|
+
function generateToken() {
|
|
321
|
+
return randomBytes(32).toString("hex");
|
|
322
|
+
}
|
|
323
|
+
function ensureToken(rootPath) {
|
|
324
|
+
const envPath = join2(rootPath, ".env");
|
|
325
|
+
let contents = "";
|
|
326
|
+
if (existsSync(envPath)) {
|
|
327
|
+
contents = readFileSync(envPath, "utf-8");
|
|
328
|
+
}
|
|
329
|
+
const match = contents.match(/^CANCIA_TOKEN=(\S+)$/m);
|
|
330
|
+
if (match?.[1]) return match[1];
|
|
331
|
+
const token = generateToken();
|
|
332
|
+
if (/^CANCIA_TOKEN=\s*$/m.test(contents)) {
|
|
333
|
+
writeFileSync(envPath, contents.replace(/^CANCIA_TOKEN=\s*$/m, `CANCIA_TOKEN=${token}`), "utf-8");
|
|
334
|
+
} else {
|
|
335
|
+
writeFileSync(envPath, contents + `
|
|
336
|
+
CANCIA_TOKEN=${token}
|
|
337
|
+
`, "utf-8");
|
|
338
|
+
}
|
|
339
|
+
return token;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/integration.ts
|
|
343
|
+
function canciaIntegration(opts = {}) {
|
|
344
|
+
let storage;
|
|
345
|
+
let resolvedToken = "";
|
|
346
|
+
let resolvedSite = "";
|
|
347
|
+
let resolvedRootPath = "";
|
|
348
|
+
let resolvedUploadHandler;
|
|
349
|
+
let resolvedDeployHook;
|
|
350
|
+
let resolvedLocales = ["en"];
|
|
351
|
+
return {
|
|
352
|
+
name: "@cancia/astro",
|
|
353
|
+
hooks: {
|
|
354
|
+
"astro:config:setup": ({ injectScript, injectRoute, updateConfig, config }) => {
|
|
355
|
+
resolvedRootPath = config.root.pathname.replace(/^\/([A-Za-z]:)/, "$1").replace(/\/$/, "");
|
|
356
|
+
const rootPath = resolvedRootPath;
|
|
357
|
+
const env = loadEnv(process.env.NODE_ENV ?? "development", rootPath, "");
|
|
358
|
+
const site = opts.site ?? env.CANCIA_SITE ?? process.env.CANCIA_SITE ?? "default";
|
|
359
|
+
const accentColor = opts.accentColor ?? "#6366f1";
|
|
360
|
+
const languages = opts.languages ?? (() => {
|
|
361
|
+
const i18n = config.i18n;
|
|
362
|
+
if (!i18n) return ["en"];
|
|
363
|
+
const all = i18n.locales.flatMap(
|
|
364
|
+
(l) => typeof l === "string" ? [l] : l.codes
|
|
365
|
+
);
|
|
366
|
+
const def = i18n.defaultLocale;
|
|
367
|
+
return [def, ...all.filter((l) => l !== def)];
|
|
368
|
+
})();
|
|
369
|
+
resolvedLocales = languages;
|
|
370
|
+
const envToken = env.CANCIA_TOKEN?.trim() || process.env.CANCIA_TOKEN?.trim() || "";
|
|
371
|
+
resolvedToken = opts.token ?? (envToken || ensureToken(rootPath));
|
|
372
|
+
resolvedSite = config.site?.replace(/\/$/, "") ?? "";
|
|
373
|
+
const token = resolvedToken;
|
|
374
|
+
const isNewToken = !opts.token && !envToken;
|
|
375
|
+
if (isNewToken) {
|
|
376
|
+
console.log(`
|
|
377
|
+
\x1B[32mcancia\x1B[0m No CANCIA_TOKEN found \u2014 generated one for you.`);
|
|
378
|
+
}
|
|
379
|
+
const hasDeployHook = !!(opts.deployHook ?? env.CANCIA_DEPLOY_HOOK ?? process.env.CANCIA_DEPLOY_HOOK);
|
|
380
|
+
injectScript(
|
|
381
|
+
"head-inline",
|
|
382
|
+
`window.__CANCIA__=${JSON.stringify({
|
|
383
|
+
apiUrl: "",
|
|
384
|
+
// empty = same origin
|
|
385
|
+
site,
|
|
386
|
+
accentColor,
|
|
387
|
+
languages,
|
|
388
|
+
page: "unknown",
|
|
389
|
+
public: opts.public ?? false,
|
|
390
|
+
hasDeployHook
|
|
391
|
+
})};`
|
|
392
|
+
);
|
|
393
|
+
injectScript("page", `import "@cancia/toolbar";`);
|
|
394
|
+
const endpointsDir = new URL("./endpoints/", import.meta.url);
|
|
395
|
+
const endpointPath = (name) => fileURLToPath(new URL(`${name}.js`, endpointsDir));
|
|
396
|
+
injectRoute({ pattern: "/api/cancia/content", entrypoint: endpointPath("content"), prerender: false });
|
|
397
|
+
injectRoute({ pattern: "/api/cancia/save", entrypoint: endpointPath("content"), prerender: false });
|
|
398
|
+
injectRoute({ pattern: "/api/cancia/upload", entrypoint: endpointPath("upload"), prerender: false });
|
|
399
|
+
injectRoute({ pattern: "/api/cancia/publish", entrypoint: endpointPath("publish"), prerender: false });
|
|
400
|
+
injectRoute({ pattern: "/api/cancia/auth", entrypoint: endpointPath("auth"), prerender: false });
|
|
401
|
+
injectRoute({ pattern: "/api/cancia/health", entrypoint: endpointPath("health"), prerender: false });
|
|
402
|
+
injectRoute({ pattern: "/api/cancia/schemas", entrypoint: endpointPath("schemas"), prerender: false });
|
|
403
|
+
injectRoute({ pattern: "/api/cancia/lists/[listName]", entrypoint: endpointPath("lists"), prerender: false });
|
|
404
|
+
injectRoute({ pattern: "/api/cancia/lists/[listName]/[id]", entrypoint: endpointPath("lists"), prerender: false });
|
|
405
|
+
updateConfig({
|
|
406
|
+
vite: {
|
|
407
|
+
plugins: [
|
|
408
|
+
{
|
|
409
|
+
name: "vite-plugin-cancia-runtime",
|
|
410
|
+
resolveId(id) {
|
|
411
|
+
if (id === "virtual:cancia/runtime") {
|
|
412
|
+
return fileURLToPath(new URL("./runtime.js", import.meta.url));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
"astro:server:setup": ({ server }) => {
|
|
421
|
+
storage = opts.storage ?? createJsonFileAdapter(opts.dbPath);
|
|
422
|
+
const secret = resolvedToken;
|
|
423
|
+
const deployHook = opts.deployHook ?? process.env.CANCIA_DEPLOY_HOOK;
|
|
424
|
+
let uploadHandler;
|
|
425
|
+
if (opts.r2) {
|
|
426
|
+
const serverEnv = loadEnv(process.env.NODE_ENV ?? "development", resolvedRootPath, "");
|
|
427
|
+
const r2Opts = typeof opts.r2 === "function" ? opts.r2() : opts.r2;
|
|
428
|
+
const resolved = {
|
|
429
|
+
accountId: r2Opts.accountId ?? serverEnv.R2_ACCOUNT_ID,
|
|
430
|
+
bucket: r2Opts.bucket ?? serverEnv.R2_BUCKET_NAME ?? serverEnv.R2_BUCKET,
|
|
431
|
+
accessKeyId: r2Opts.accessKeyId ?? serverEnv.R2_ACCESS_KEY_ID,
|
|
432
|
+
secretAccessKey: r2Opts.secretAccessKey ?? serverEnv.R2_SECRET_ACCESS_KEY,
|
|
433
|
+
publicUrl: r2Opts.publicUrl ?? serverEnv.R2_PUBLIC_URL,
|
|
434
|
+
prefix: r2Opts.prefix
|
|
435
|
+
};
|
|
436
|
+
uploadHandler = makeR2UploadHandler(resolved);
|
|
437
|
+
} else {
|
|
438
|
+
uploadHandler = opts.uploadHandler ?? makeLocalUploadHandler({ maxMB: opts.maxUploadMB });
|
|
439
|
+
}
|
|
440
|
+
const routeSecret = opts.public ? void 0 : secret || void 0;
|
|
441
|
+
resolvedUploadHandler = uploadHandler;
|
|
442
|
+
resolvedDeployHook = deployHook;
|
|
443
|
+
const storageV2 = opts.storageV2 === null ? void 0 : opts.storageV2 ?? createJsonFileAdapterV2({ projectRoot: resolvedRootPath });
|
|
444
|
+
setCanciaRuntime({
|
|
445
|
+
storage,
|
|
446
|
+
storageV2,
|
|
447
|
+
projectRoot: resolvedRootPath,
|
|
448
|
+
schemasPath: opts.schemasPath,
|
|
449
|
+
defaultLocale: resolvedLocales[0],
|
|
450
|
+
locales: resolvedLocales,
|
|
451
|
+
secret: routeSecret,
|
|
452
|
+
uploadHandler,
|
|
453
|
+
deployHook,
|
|
454
|
+
maxUploadMB: opts.maxUploadMB ?? 10
|
|
455
|
+
});
|
|
456
|
+
const contentRoutes = makeContentRoute(storage, routeSecret);
|
|
457
|
+
const uploadRoute = makeUploadRoute(uploadHandler, routeSecret, opts.maxUploadMB);
|
|
458
|
+
const publishRoute = makePublishRoute(deployHook, routeSecret);
|
|
459
|
+
const authRoute = makeAuthRoute(secret);
|
|
460
|
+
const listsRoutes = makeListsRoutes({
|
|
461
|
+
storageV2,
|
|
462
|
+
projectRoot: resolvedRootPath,
|
|
463
|
+
schemasPath: opts.schemasPath,
|
|
464
|
+
secret: routeSecret,
|
|
465
|
+
defaultLocale: resolvedLocales[0]
|
|
466
|
+
});
|
|
467
|
+
const schemasRoute = makeSchemasRoute({
|
|
468
|
+
projectRoot: resolvedRootPath,
|
|
469
|
+
schemasPath: opts.schemasPath,
|
|
470
|
+
secret: routeSecret
|
|
471
|
+
});
|
|
472
|
+
server.middlewares.use(async (req, res, next) => {
|
|
473
|
+
const url = req.url ?? "";
|
|
474
|
+
if (!url.startsWith("/api/cancia/")) return next();
|
|
475
|
+
const method = req.method ?? "GET";
|
|
476
|
+
let body;
|
|
477
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
478
|
+
const IDLE_TIMEOUT_MS = 1e4;
|
|
479
|
+
const read = await new Promise((resolve) => {
|
|
480
|
+
const chunks = [];
|
|
481
|
+
let timer;
|
|
482
|
+
const arm = () => {
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
timer = setTimeout(() => resolve({ failed: true }), IDLE_TIMEOUT_MS);
|
|
485
|
+
};
|
|
486
|
+
arm();
|
|
487
|
+
req.on("data", (c) => {
|
|
488
|
+
chunks.push(c);
|
|
489
|
+
arm();
|
|
490
|
+
});
|
|
491
|
+
req.on("end", () => {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
resolve({ buf: Buffer.concat(chunks) });
|
|
494
|
+
});
|
|
495
|
+
req.on("error", () => {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
resolve({ failed: true });
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
if ("failed" in read) {
|
|
501
|
+
res.statusCode = 408;
|
|
502
|
+
res.end(JSON.stringify({ error: "Request body timed out or errored" }));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
body = read.buf.length ? read.buf : void 0;
|
|
506
|
+
}
|
|
507
|
+
const headers = {};
|
|
508
|
+
for (let i = 0; i < (req.rawHeaders?.length ?? 0); i += 2) {
|
|
509
|
+
headers[req.rawHeaders[i].toLowerCase()] = req.rawHeaders[i + 1];
|
|
510
|
+
}
|
|
511
|
+
const fullUrl = `http://localhost${url}`;
|
|
512
|
+
const webReq = new Request(fullUrl, {
|
|
513
|
+
method,
|
|
514
|
+
headers,
|
|
515
|
+
body: body?.length ? body : void 0
|
|
516
|
+
});
|
|
517
|
+
const ctx = { request: webReq };
|
|
518
|
+
let response;
|
|
519
|
+
if (url.startsWith("/api/cancia/lists/")) {
|
|
520
|
+
response = await listsRoutes.handle(webReq, method);
|
|
521
|
+
} else if (url.startsWith("/api/cancia/schemas")) {
|
|
522
|
+
response = await schemasRoute(webReq);
|
|
523
|
+
} else if (url.startsWith("/api/cancia/content") && method === "GET") {
|
|
524
|
+
response = await contentRoutes.GET(ctx);
|
|
525
|
+
} else if (url.startsWith("/api/cancia/save") && method === "POST") {
|
|
526
|
+
response = await contentRoutes.POST(ctx);
|
|
527
|
+
} else if (url.startsWith("/api/cancia/content") && method === "DELETE") {
|
|
528
|
+
response = await contentRoutes.DELETE(ctx);
|
|
529
|
+
} else if (url.startsWith("/api/cancia/upload") && method === "POST") {
|
|
530
|
+
response = await uploadRoute(ctx);
|
|
531
|
+
} else if (url.startsWith("/api/cancia/publish") && method === "POST") {
|
|
532
|
+
response = await publishRoute(ctx);
|
|
533
|
+
} else if (url === "/api/cancia/health") {
|
|
534
|
+
response = new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
535
|
+
} else if (url === "/api/cancia/auth" && method === "POST") {
|
|
536
|
+
response = await authRoute(webReq);
|
|
537
|
+
}
|
|
538
|
+
if (!response) return next();
|
|
539
|
+
res.statusCode = response.status;
|
|
540
|
+
response.headers.forEach((v, k) => res.setHeader(k, v));
|
|
541
|
+
const text = await response.text();
|
|
542
|
+
res.end(text);
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
"astro:server:start": ({ address }) => {
|
|
546
|
+
const raw = address.address;
|
|
547
|
+
const host = raw === "::" || raw === "::1" || raw === "0.0.0.0" || raw === "127.0.0.1" ? "localhost" : raw;
|
|
548
|
+
const localUrl = `http://${host}:${address.port}`;
|
|
549
|
+
console.log(`
|
|
550
|
+
\x1B[32mcancia\x1B[0m Client editor link:
|
|
551
|
+
`);
|
|
552
|
+
console.log(` \x1B[2mlocal \x1B[0m \x1B[36m${localUrl}?cancia=${resolvedToken}\x1B[0m`);
|
|
553
|
+
if (resolvedSite) {
|
|
554
|
+
console.log(` \x1B[2msite \x1B[0m \x1B[36m${resolvedSite}?cancia=${resolvedToken}\x1B[0m`);
|
|
555
|
+
} else {
|
|
556
|
+
console.log(`
|
|
557
|
+
\x1B[2mTip: set \`site: "https://yoursite.com"\` in astro.config.mjs to get the production URL.\x1B[0m`);
|
|
558
|
+
}
|
|
559
|
+
console.log();
|
|
560
|
+
},
|
|
561
|
+
"astro:config:done": ({ buildOutput, logger }) => {
|
|
562
|
+
if (buildOutput === "static") {
|
|
563
|
+
logger.warn(
|
|
564
|
+
"Output is static \u2014 Cancia API routes won't be available at runtime. Set output: 'server' (or 'hybrid') in astro.config.mjs, or point the toolbar at an external Cancia API server via the apiUrl option."
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
// For production SSR builds: init storage + runtime singleton before requests are served
|
|
569
|
+
"astro:build:start": () => {
|
|
570
|
+
storage = opts.storage ?? createJsonFileAdapter(opts.dbPath);
|
|
571
|
+
const secret = resolvedToken;
|
|
572
|
+
const routeSecret = opts.public ? void 0 : secret || void 0;
|
|
573
|
+
let uploadHandler;
|
|
574
|
+
if (opts.r2) {
|
|
575
|
+
const buildEnv = loadEnv(process.env.NODE_ENV ?? "production", resolvedRootPath, "");
|
|
576
|
+
const r2Opts = typeof opts.r2 === "function" ? opts.r2() : opts.r2;
|
|
577
|
+
const resolved = {
|
|
578
|
+
accountId: r2Opts.accountId ?? buildEnv.R2_ACCOUNT_ID,
|
|
579
|
+
bucket: r2Opts.bucket ?? buildEnv.R2_BUCKET_NAME ?? buildEnv.R2_BUCKET,
|
|
580
|
+
accessKeyId: r2Opts.accessKeyId ?? buildEnv.R2_ACCESS_KEY_ID,
|
|
581
|
+
secretAccessKey: r2Opts.secretAccessKey ?? buildEnv.R2_SECRET_ACCESS_KEY,
|
|
582
|
+
publicUrl: r2Opts.publicUrl ?? buildEnv.R2_PUBLIC_URL,
|
|
583
|
+
prefix: r2Opts.prefix
|
|
584
|
+
};
|
|
585
|
+
uploadHandler = makeR2UploadHandler(resolved);
|
|
586
|
+
} else {
|
|
587
|
+
uploadHandler = opts.uploadHandler ?? makeLocalUploadHandler({ maxMB: opts.maxUploadMB });
|
|
588
|
+
}
|
|
589
|
+
const deployHook = opts.deployHook ?? process.env.CANCIA_DEPLOY_HOOK;
|
|
590
|
+
const storageV2 = opts.storageV2 === null ? void 0 : opts.storageV2 ?? createJsonFileAdapterV2({ projectRoot: resolvedRootPath });
|
|
591
|
+
setCanciaRuntime({
|
|
592
|
+
storage,
|
|
593
|
+
storageV2,
|
|
594
|
+
projectRoot: resolvedRootPath,
|
|
595
|
+
schemasPath: opts.schemasPath,
|
|
596
|
+
defaultLocale: resolvedLocales[0],
|
|
597
|
+
locales: resolvedLocales,
|
|
598
|
+
secret: routeSecret,
|
|
599
|
+
uploadHandler,
|
|
600
|
+
deployHook,
|
|
601
|
+
maxUploadMB: opts.maxUploadMB ?? 10
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/fetch-cms.ts
|
|
609
|
+
async function fetchCMSData(opts) {
|
|
610
|
+
const { apiUrl, site, token } = opts;
|
|
611
|
+
const url = `${apiUrl}/api/cancia/content?site=${encodeURIComponent(site)}`;
|
|
612
|
+
const headers = {};
|
|
613
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
614
|
+
try {
|
|
615
|
+
const res = await fetch(url, { headers });
|
|
616
|
+
if (!res.ok) {
|
|
617
|
+
console.warn(`[cancia] Failed to fetch CMS data (${res.status}). Using defaults.`);
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
return res.json();
|
|
621
|
+
} catch (err) {
|
|
622
|
+
console.warn("[cancia] CMS API unreachable. Using defaults.", err);
|
|
623
|
+
return {};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/use-translations.ts
|
|
628
|
+
function makeUseTranslations(ui, defaultLang) {
|
|
629
|
+
return function useTranslations(lang, cmsData) {
|
|
630
|
+
return function t(key) {
|
|
631
|
+
const cmsKey = `${String(key)}.${String(lang)}`;
|
|
632
|
+
if (cmsData?.[cmsKey] !== void 0) return cmsData[cmsKey];
|
|
633
|
+
return ui[lang]?.[key] ?? ui[defaultLang]?.[key] ?? String(key);
|
|
634
|
+
};
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/storage/sqlite.ts
|
|
639
|
+
import { createRequire } from "module";
|
|
640
|
+
var require2 = createRequire(import.meta.url);
|
|
641
|
+
var _db = null;
|
|
642
|
+
function createDB(dbPath) {
|
|
643
|
+
const Database = require2("better-sqlite3");
|
|
644
|
+
const sqlite = new Database(dbPath);
|
|
645
|
+
sqlite.pragma("journal_mode = WAL");
|
|
646
|
+
sqlite.exec(`
|
|
647
|
+
CREATE TABLE IF NOT EXISTS cancia_content (
|
|
648
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
649
|
+
site TEXT NOT NULL,
|
|
650
|
+
key TEXT NOT NULL,
|
|
651
|
+
lang TEXT NOT NULL,
|
|
652
|
+
value TEXT NOT NULL,
|
|
653
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
654
|
+
UNIQUE(site, key, lang)
|
|
655
|
+
)
|
|
656
|
+
`);
|
|
657
|
+
return {
|
|
658
|
+
get: sqlite.prepare(
|
|
659
|
+
"SELECT value FROM cancia_content WHERE site=? AND key=? AND lang=?"
|
|
660
|
+
),
|
|
661
|
+
set: sqlite.prepare(
|
|
662
|
+
`INSERT INTO cancia_content (site, key, lang, value, updated_at)
|
|
663
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
664
|
+
ON CONFLICT(site, key, lang) DO UPDATE SET value=excluded.value, updated_at=unixepoch()`
|
|
665
|
+
),
|
|
666
|
+
getAll: sqlite.prepare(
|
|
667
|
+
"SELECT key, lang, value FROM cancia_content WHERE site=?"
|
|
668
|
+
),
|
|
669
|
+
delete: sqlite.prepare(
|
|
670
|
+
"DELETE FROM cancia_content WHERE site=? AND key=? AND lang=?"
|
|
671
|
+
)
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function getDB(dbPath) {
|
|
675
|
+
if (!_db) _db = createDB(dbPath);
|
|
676
|
+
return _db;
|
|
677
|
+
}
|
|
678
|
+
function createSQLiteAdapter(dbPath) {
|
|
679
|
+
const path = dbPath ?? process.cwd() + "/cancia.db";
|
|
680
|
+
return {
|
|
681
|
+
async get(site, key, lang) {
|
|
682
|
+
const row = getDB(path).get.get(site, key, lang);
|
|
683
|
+
return row?.value ?? null;
|
|
684
|
+
},
|
|
685
|
+
async set(site, key, lang, value) {
|
|
686
|
+
getDB(path).set.run(site, key, lang, value);
|
|
687
|
+
},
|
|
688
|
+
async getAll(site) {
|
|
689
|
+
const rows = getDB(path).getAll.all(site);
|
|
690
|
+
return Object.fromEntries(rows.map((r) => [`${r.key}.${r.lang}`, r.value]));
|
|
691
|
+
},
|
|
692
|
+
async delete(site, key, lang) {
|
|
693
|
+
getDB(path).delete.run(site, key, lang);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
export {
|
|
698
|
+
RevConflictError,
|
|
699
|
+
canciaIntegration,
|
|
700
|
+
canciaLoader,
|
|
701
|
+
createJsonFileAdapter,
|
|
702
|
+
createJsonFileAdapterV2,
|
|
703
|
+
createSQLiteAdapter,
|
|
704
|
+
canciaIntegration as default,
|
|
705
|
+
defineList,
|
|
706
|
+
describeList,
|
|
707
|
+
fetchCMSData,
|
|
708
|
+
makeLocalUploadHandler,
|
|
709
|
+
makeR2UploadHandler,
|
|
710
|
+
makeUseTranslations,
|
|
711
|
+
z
|
|
712
|
+
};
|