@cancia/astro 0.10.0 → 0.12.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.
Files changed (44) hide show
  1. package/dist/chunk-4PYVIIOO.js +25 -0
  2. package/dist/{chunk-DIV2FFYX.js → chunk-5R3I5RLT.js} +15 -8
  3. package/dist/chunk-6GUOGDWO.js +172 -0
  4. package/dist/chunk-DEI3FBNY.js +65 -0
  5. package/dist/{chunk-ONKCUTPU.js → chunk-DOFLZ7KG.js} +6 -7
  6. package/dist/{chunk-GJIX7MWC.js → chunk-ECPYHIAW.js} +9 -1
  7. package/dist/{chunk-YGJ3JHXF.js → chunk-QQEUCOXQ.js} +11 -0
  8. package/dist/{chunk-NVXNJ4YC.js → chunk-R7VA3Z5N.js} +53 -11
  9. package/dist/{chunk-5IPHDIC6.js → chunk-RHFTQW4W.js} +10 -1
  10. package/dist/{chunk-2NVZWZPE.js → chunk-S5YLB6P3.js} +310 -7
  11. package/dist/{chunk-LJSSPOSW.js → chunk-VF3EXXEM.js} +139 -16
  12. package/dist/chunk-XINNLVZP.js +183 -0
  13. package/dist/chunk-XZCXWILA.js +145 -0
  14. package/dist/content.js +3 -3
  15. package/dist/{upload-DwCGjXbz.d.ts → email-BRK4WAO2.d.ts} +9 -1
  16. package/dist/endpoints/auth-callback.d.ts +1 -0
  17. package/dist/endpoints/auth-callback.js +9 -0
  18. package/dist/endpoints/auth-logout.d.ts +1 -0
  19. package/dist/endpoints/auth-logout.js +9 -0
  20. package/dist/endpoints/auth-request.d.ts +18 -0
  21. package/dist/endpoints/auth-request.js +9 -0
  22. package/dist/endpoints/auth-session.d.ts +1 -0
  23. package/dist/endpoints/auth-session.js +9 -0
  24. package/dist/endpoints/auth.js +10 -2
  25. package/dist/endpoints/content.js +12 -12
  26. package/dist/endpoints/lists.js +4 -1
  27. package/dist/endpoints/publish.js +8 -6
  28. package/dist/endpoints/schemas.js +6 -3
  29. package/dist/endpoints/upload.js +11 -10
  30. package/dist/{git-backed-DtiH52EI.d.ts → git-backed-CwTFfUS4.d.ts} +2 -2
  31. package/dist/{github-client-Db0pIrqE.d.ts → github-client-Db5EPcSL.d.ts} +1 -1
  32. package/dist/index.d.ts +32 -6
  33. package/dist/index.js +125 -35
  34. package/dist/loader/index.d.ts +34 -4
  35. package/dist/loader/index.js +3 -3
  36. package/dist/runtime.d.ts +69 -3
  37. package/dist/runtime.js +6 -4
  38. package/dist/schema/index.d.ts +14 -1
  39. package/dist/schema/index.js +1 -1
  40. package/dist/storage/index.d.ts +4 -4
  41. package/dist/storage/index.js +5 -5
  42. package/dist/{types-BMlLS-OS.d.ts → types-CKzfz5iu.d.ts} +1 -1
  43. package/package.json +1 -1
  44. package/dist/chunk-MXVOJRAM.js +0 -504
@@ -0,0 +1,25 @@
1
+ import {
2
+ authorizeRequest
3
+ } from "./chunk-6GUOGDWO.js";
4
+
5
+ // src/auth/guard.ts
6
+ var unauthorized = () => new Response(JSON.stringify({ error: "Unauthorized" }), {
7
+ status: 401,
8
+ headers: { "Content-Type": "application/json" }
9
+ });
10
+ async function guardRequest(request, cfg) {
11
+ if (!cfg.secret && !cfg.auth) {
12
+ return { deny: null, email: null };
13
+ }
14
+ const result = await authorizeRequest({
15
+ request,
16
+ secret: cfg.secret,
17
+ auth: cfg.auth
18
+ });
19
+ if (!result.ok) return { deny: unauthorized(), email: null };
20
+ return { deny: null, email: result.email };
21
+ }
22
+
23
+ export {
24
+ guardRequest
25
+ };
@@ -1,3 +1,6 @@
1
+ import {
2
+ guardRequest
3
+ } from "./chunk-4PYVIIOO.js";
1
4
  import {
2
5
  loadListSchema
3
6
  } from "./chunk-VL6FO446.js";
@@ -23,13 +26,8 @@ function getSite(request) {
23
26
  function getLocale(request) {
24
27
  return new URL(request.url).searchParams.get("locale");
25
28
  }
26
- function checkAuth(request, secret) {
27
- if (!secret) return null;
28
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
29
- if (token !== secret) {
30
- return json({ error: "Unauthorized" }, 401);
31
- }
32
- return null;
29
+ async function checkAuth(request, secret, auth) {
30
+ return (await guardRequest(request, { secret, auth })).deny;
33
31
  }
34
32
  function json(body, status = 200) {
35
33
  return new Response(JSON.stringify(body), {
@@ -45,7 +43,7 @@ function makeListsRoutes(ctx) {
45
43
  if (!ctx.storageV2) {
46
44
  return json({ error: "Storage v2 not configured", code: "NO_STORAGE_V2" }, 503);
47
45
  }
48
- const deny = checkAuth(request, ctx.secret);
46
+ const deny = await checkAuth(request, ctx.secret, ctx.auth);
49
47
  if (deny) return deny;
50
48
  const parsed = parseListPath(request.url);
51
49
  if (!parsed) return json({ error: "Bad request" }, 400);
@@ -103,6 +101,15 @@ function makeListsRoutes(ctx) {
103
101
  if (!body || !Array.isArray(body.ids) || !body.ids.every((id2) => typeof id2 === "string")) {
104
102
  return json({ error: "`ids` must be a string array" }, 400);
105
103
  }
104
+ if (typeof body.knownCount === "number") {
105
+ const existing = await lists.translations(site, parsed.listName);
106
+ if (existing.length !== body.knownCount) {
107
+ return json({
108
+ error: "This list changed while you were reordering it. Close and reopen the panel to see the latest entries.",
109
+ code: "REV_CONFLICT"
110
+ }, 409);
111
+ }
112
+ }
106
113
  try {
107
114
  await lists.reorder(site, parsed.listName, body.ids);
108
115
  return json({ ok: true });
@@ -0,0 +1,172 @@
1
+ // src/auth/cookie.ts
2
+ var SESSION_COOKIE = "cancia_session";
3
+ function serialiseSessionCookie(value, opts = {}) {
4
+ const parts = [
5
+ `${SESSION_COOKIE}=${value}`,
6
+ "Path=/",
7
+ "HttpOnly",
8
+ "SameSite=Lax",
9
+ `Max-Age=${opts.maxAgeSeconds ?? 60 * 60 * 24 * 30}`
10
+ ];
11
+ if (opts.secure !== false) parts.push("Secure");
12
+ return parts.join("; ");
13
+ }
14
+ function clearSessionCookie(opts = {}) {
15
+ const parts = [
16
+ `${SESSION_COOKIE}=`,
17
+ "Path=/",
18
+ "HttpOnly",
19
+ "SameSite=Lax",
20
+ "Max-Age=0"
21
+ ];
22
+ if (opts.secure !== false) parts.push("Secure");
23
+ return parts.join("; ");
24
+ }
25
+ function readSessionCookie(request) {
26
+ const header = request.headers.get("cookie");
27
+ if (!header) return void 0;
28
+ const values = [];
29
+ for (const pair of header.split(";")) {
30
+ const eq = pair.indexOf("=");
31
+ if (eq === -1) continue;
32
+ if (pair.slice(0, eq).trim() !== SESSION_COOKIE) continue;
33
+ const value = pair.slice(eq + 1).trim();
34
+ if (value !== "") values.push(value);
35
+ }
36
+ return values.length > 0 ? values[values.length - 1] : void 0;
37
+ }
38
+ function isSecureRequest(request) {
39
+ const proto = request.headers.get("x-forwarded-proto");
40
+ if (proto) return proto.split(",")[0].trim() === "https";
41
+ try {
42
+ const url = new URL(request.url);
43
+ if (url.protocol === "https:") return true;
44
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return false;
45
+ } catch {
46
+ }
47
+ return true;
48
+ }
49
+
50
+ // src/auth/tokens.ts
51
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
52
+ var TOKEN_BYTES = 32;
53
+ function mintToken() {
54
+ return randomBytes(TOKEN_BYTES).toString("base64url");
55
+ }
56
+ function hashToken(raw) {
57
+ return createHash("sha256").update(raw, "utf8").digest("hex");
58
+ }
59
+ function safeEqual(a, b) {
60
+ const bufA = Buffer.from(a, "utf8");
61
+ const bufB = Buffer.from(b, "utf8");
62
+ if (bufA.length !== bufB.length) {
63
+ timingSafeEqual(bufA, bufA);
64
+ return false;
65
+ }
66
+ return timingSafeEqual(bufA, bufB);
67
+ }
68
+ function normaliseEmail(email) {
69
+ return email.trim().toLowerCase();
70
+ }
71
+
72
+ // src/auth/magic-link.ts
73
+ var LOGIN_TOKEN_TTL_MS = 15 * 60 * 1e3;
74
+ var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
75
+ var SESSION_TOUCH_AFTER_MS = 24 * 60 * 60 * 1e3;
76
+ async function requestLoginLink(store, site, rawEmail, now = Date.now()) {
77
+ const email = normaliseEmail(rawEmail);
78
+ if (!await store.isEditor(site, email)) {
79
+ return { token: null, email };
80
+ }
81
+ const token = mintToken();
82
+ await store.createLoginToken(site, {
83
+ hash: hashToken(token),
84
+ email,
85
+ expiresAt: new Date(now + LOGIN_TOKEN_TTL_MS).toISOString()
86
+ });
87
+ return { token, email };
88
+ }
89
+ var FAILED = { sessionToken: null, email: null, expiresAt: null };
90
+ async function redeemLoginToken(store, site, rawToken, now = Date.now()) {
91
+ if (!rawToken) return FAILED;
92
+ const record = await store.consumeLoginToken(site, hashToken(rawToken));
93
+ if (!record) return FAILED;
94
+ if (Date.parse(record.expiresAt) <= now) return FAILED;
95
+ if (!await store.isEditor(site, record.email)) return FAILED;
96
+ const sessionToken = mintToken();
97
+ const expiresAt = new Date(now + SESSION_TTL_MS).toISOString();
98
+ await store.createSession(site, {
99
+ id: hashToken(sessionToken),
100
+ email: record.email,
101
+ createdAt: new Date(now).toISOString(),
102
+ expiresAt
103
+ });
104
+ return { sessionToken, email: record.email, expiresAt };
105
+ }
106
+ var INVALID = { valid: false, email: null };
107
+ async function verifySession(store, site, rawToken, now = Date.now()) {
108
+ if (!rawToken) return INVALID;
109
+ const id = hashToken(rawToken);
110
+ const session = await store.getSession(site, id);
111
+ if (!session) return INVALID;
112
+ if (Date.parse(session.expiresAt) <= now) {
113
+ await store.deleteSession(site, id);
114
+ return INVALID;
115
+ }
116
+ if (!await store.isEditor(site, session.email)) {
117
+ await store.deleteSession(site, id);
118
+ return INVALID;
119
+ }
120
+ const elapsed = now - Date.parse(session.createdAt);
121
+ const remaining = Date.parse(session.expiresAt) - now;
122
+ if (elapsed > 0 && remaining < SESSION_TTL_MS - SESSION_TOUCH_AFTER_MS) {
123
+ const renewedExpiresAt = new Date(now + SESSION_TTL_MS).toISOString();
124
+ await store.touchSession(site, id, renewedExpiresAt);
125
+ return { valid: true, email: session.email, renewedExpiresAt };
126
+ }
127
+ return { valid: true, email: session.email };
128
+ }
129
+ async function revokeSession(store, site, rawToken) {
130
+ if (!rawToken) return;
131
+ await store.deleteSession(site, hashToken(rawToken));
132
+ }
133
+
134
+ // src/auth/authorize.ts
135
+ var DENY = { ok: false, email: null };
136
+ async function authorizeRequest(cfg) {
137
+ const { request, secret, auth, editorsConfigured } = cfg;
138
+ if (auth) {
139
+ const raw = readSessionCookie(request);
140
+ if (raw) {
141
+ const verdict = await verifySession(auth.store, auth.site, raw, cfg.now);
142
+ if (verdict.valid) {
143
+ return {
144
+ ok: true,
145
+ email: verdict.email,
146
+ renewedExpiresAt: verdict.renewedExpiresAt
147
+ };
148
+ }
149
+ }
150
+ }
151
+ const header = request.headers.get("Authorization");
152
+ if (header && secret) {
153
+ const token = header.replace("Bearer ", "").trim();
154
+ if (safeEqual(token, secret)) {
155
+ return { ok: true, email: null };
156
+ }
157
+ }
158
+ return DENY;
159
+ }
160
+
161
+ export {
162
+ serialiseSessionCookie,
163
+ clearSessionCookie,
164
+ readSessionCookie,
165
+ isSecureRequest,
166
+ normaliseEmail,
167
+ SESSION_TTL_MS,
168
+ requestLoginLink,
169
+ redeemLoginToken,
170
+ revokeSession,
171
+ authorizeRequest
172
+ };
@@ -0,0 +1,65 @@
1
+ import {
2
+ handleLoginCallback,
3
+ handleLoginRequest,
4
+ handleLogout,
5
+ handleSessionCheck,
6
+ resolveLoginEmailSender
7
+ } from "./chunk-XZCXWILA.js";
8
+ import {
9
+ createRateLimiter
10
+ } from "./chunk-ECPYHIAW.js";
11
+ import {
12
+ authorizeRequest
13
+ } from "./chunk-6GUOGDWO.js";
14
+
15
+ // src/endpoints/auth-magic.ts
16
+ import { getCanciaRuntime } from "virtual:cancia/runtime";
17
+ var limiter = createRateLimiter();
18
+ var notConfigured = () => new Response(JSON.stringify({ error: "Magic-link login is not configured for this site." }), {
19
+ status: 404,
20
+ headers: { "Content-Type": "application/json" }
21
+ });
22
+ function ctxFrom(rt) {
23
+ if (!rt.auth) return null;
24
+ return {
25
+ store: rt.auth.store,
26
+ site: rt.auth.site,
27
+ sendEmail: resolveLoginEmailSender(rt.sendLoginEmail),
28
+ limiter
29
+ };
30
+ }
31
+ async function requestLink({ request }) {
32
+ const rt = getCanciaRuntime();
33
+ const ctx = ctxFrom(rt);
34
+ if (!ctx) return notConfigured();
35
+ return handleLoginRequest(request, ctx, new URL(request.url).origin);
36
+ }
37
+ async function callback({ request }) {
38
+ const rt = getCanciaRuntime();
39
+ const ctx = ctxFrom(rt);
40
+ if (!ctx) return notConfigured();
41
+ return handleLoginCallback(request, ctx);
42
+ }
43
+ async function logout({ request }) {
44
+ const rt = getCanciaRuntime();
45
+ const ctx = ctxFrom(rt);
46
+ if (!ctx) return notConfigured();
47
+ return handleLogout(request, ctx);
48
+ }
49
+ async function session({ request }) {
50
+ const rt = getCanciaRuntime();
51
+ const ctx = ctxFrom(rt);
52
+ if (!ctx) return notConfigured();
53
+ return handleSessionCheck(
54
+ request,
55
+ ctx,
56
+ () => authorizeRequest({ request, secret: rt.secret, auth: rt.auth })
57
+ );
58
+ }
59
+
60
+ export {
61
+ requestLink,
62
+ callback,
63
+ logout,
64
+ session
65
+ };
@@ -1,9 +1,12 @@
1
+ import {
2
+ guardRequest
3
+ } from "./chunk-4PYVIIOO.js";
1
4
  import {
2
5
  loadSchemas
3
6
  } from "./chunk-VL6FO446.js";
4
7
  import {
5
8
  describeList
6
- } from "./chunk-YGJ3JHXF.js";
9
+ } from "./chunk-QQEUCOXQ.js";
7
10
 
8
11
  // src/routes/schemas.ts
9
12
  function json(body, status = 200) {
@@ -14,12 +17,8 @@ function json(body, status = 200) {
14
17
  }
15
18
  function makeSchemasRoute(ctx) {
16
19
  return async function schemasRoute(request) {
17
- if (ctx.secret) {
18
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
19
- if (token !== ctx.secret) {
20
- return json({ error: "Unauthorized" }, 401);
21
- }
22
- }
20
+ const deny = (await guardRequest(request, { secret: ctx.secret, auth: ctx.auth })).deny;
21
+ if (deny) return deny;
23
22
  const schemas = await loadSchemas(ctx.projectRoot, ctx.schemasPath);
24
23
  const descriptions = {};
25
24
  for (const [name, schema] of Object.entries(schemas)) {
@@ -5,7 +5,12 @@ function createRateLimiter() {
5
5
  return /* @__PURE__ */ new Map();
6
6
  }
7
7
  function getIp(req) {
8
- return req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? "unknown";
8
+ const forwarded = req.headers.get("x-forwarded-for");
9
+ if (forwarded) {
10
+ const hops = forwarded.split(",").map((h) => h.trim()).filter(Boolean);
11
+ if (hops.length > 0) return hops[hops.length - 1];
12
+ }
13
+ return req.headers.get("x-real-ip") ?? "unknown";
9
14
  }
10
15
  function isRateLimited(limiter, ip, now = Date.now()) {
11
16
  const bucket = limiter.get(ip);
@@ -51,5 +56,8 @@ async function runAuth(cfg) {
51
56
 
52
57
  export {
53
58
  createRateLimiter,
59
+ getIp,
60
+ isRateLimited,
61
+ recordFailure,
54
62
  runAuth
55
63
  };
@@ -45,6 +45,17 @@ var defineField = {
45
45
  },
46
46
  slug: (o) => z.string().regex(/^[a-z0-9-]+$/).meta({ widget: "slug", ...o }),
47
47
  image: (o) => z.string().url().meta({ widget: "image", ...o }),
48
+ /**
49
+ * An uploaded document — a PDF today.
50
+ *
51
+ * Stores a URL exactly as `image` does, because the upload path is the same:
52
+ * the file goes to the configured store (R2, or public/uploads) and what
53
+ * comes back is a plain URL. A separate widget rather than reusing `image`
54
+ * so the editor renders a filename and a download affordance instead of a
55
+ * thumbnail, and so an `image` field cannot silently accept a PDF and
56
+ * render a broken <img>.
57
+ */
58
+ file: (o) => z.string().url().meta({ widget: "file", ...o }),
48
59
  select: (o) => z.enum(o.options).meta({ widget: "select", ...o }),
49
60
  /**
50
61
  * A repeatable list of a single member type. `member` is any Zod type,
@@ -1,16 +1,33 @@
1
1
  import {
2
- createJsonFileAdapterV2
3
- } from "./chunk-2NVZWZPE.js";
2
+ createJsonFileAdapterV2,
3
+ createSqliteAdapterV2
4
+ } from "./chunk-S5YLB6P3.js";
4
5
  import {
5
6
  isDraft
6
- } from "./chunk-YGJ3JHXF.js";
7
+ } from "./chunk-QQEUCOXQ.js";
8
+
9
+ // src/loader/index.ts
10
+ import { existsSync } from "fs";
11
+ import { join as join2 } from "path";
12
+
13
+ // src/storage/resolve.ts
14
+ import { isAbsolute, join } from "path";
15
+ function resolveDbPath(dbPath, projectRoot) {
16
+ return isAbsolute(dbPath) ? dbPath : join(projectRoot, dbPath);
17
+ }
18
+ function resolveReadStorage(desc, projectRoot) {
19
+ if (desc?.kind === "sqlite-v2") {
20
+ const dbPath = desc.dbPath ? resolveDbPath(desc.dbPath, projectRoot) : join(projectRoot, "cancia.db");
21
+ return createSqliteAdapterV2({ dbPath });
22
+ }
23
+ return createJsonFileAdapterV2({ projectRoot });
24
+ }
7
25
 
8
26
  // src/loader/index.ts
9
- import { join } from "path";
10
27
  function makeId(locale, entryId) {
11
28
  return `${locale}/${entryId}`;
12
29
  }
13
- async function syncOnce(ctx, lists, list, site, schema, includeDrafts) {
30
+ async function syncOnce(ctx, lists, list, site, schema, includeDrafts, usingJsonFile = false, projectRoot = "") {
14
31
  ctx.store.clear();
15
32
  const all = await lists.list(site, list);
16
33
  const drafts = schema?.draftField && !includeDrafts ? all.filter((e) => isDraft(schema, e.data)) : [];
@@ -30,6 +47,11 @@ async function syncOnce(ctx, lists, list, site, schema, includeDrafts) {
30
47
  digest: entry._rev
31
48
  });
32
49
  }
50
+ if (entries.length === 0 && usingJsonFile && existsSync(join2(projectRoot, "cancia.db"))) {
51
+ ctx.logger.warn(
52
+ `cancia: list "${list}" is empty, but a cancia.db exists at the project root and this loader is reading JSON files. Pass db: { kind: "sqlite" } to canciaLoader() to match your integration's db option \u2014 otherwise every entry the client saves is invisible to the build.`
53
+ );
54
+ }
33
55
  const skipped = drafts.length ? ` (${drafts.length} draft${drafts.length === 1 ? "" : "s"} skipped)` : "";
34
56
  ctx.logger.info(
35
57
  `cancia: loaded ${entries.length} entr${entries.length === 1 ? "y" : "ies"} from list "${list}"${skipped}`
@@ -44,15 +66,33 @@ function canciaLoader(opts) {
44
66
  name: `@cancia/astro/loader[${opts.list}]`,
45
67
  async load(ctx) {
46
68
  latestCtx = ctx;
47
- const storage = opts.storage ?? createJsonFileAdapterV2({
48
- projectRoot: opts.projectRoot ?? process.cwd()
49
- });
69
+ const projectRoot = opts.projectRoot ?? process.cwd();
70
+ const detectedDb = !opts.storage && !opts.db && existsSync(join2(projectRoot, "cancia.db"));
71
+ const storage = opts.storage ?? resolveReadStorage(
72
+ opts.db ? { kind: opts.db.kind === "sqlite" ? "sqlite-v2" : "json-file", dbPath: opts.db.path } : detectedDb ? { kind: "sqlite-v2" } : void 0,
73
+ projectRoot
74
+ );
50
75
  const { lists } = storage;
51
- await syncOnce(ctx, lists, opts.list, opts.site, opts.schema, opts.includeDrafts);
76
+ if (detectedDb) {
77
+ ctx.logger.info(
78
+ `cancia: reading list "${opts.list}" from cancia.db (detected). Pass db: { kind: "json-file" } if that is not what you want.`
79
+ );
80
+ }
81
+ const usingJsonFile = !opts.storage && opts.db?.kind !== "sqlite" && !detectedDb;
82
+ await syncOnce(
83
+ ctx,
84
+ lists,
85
+ opts.list,
86
+ opts.site,
87
+ opts.schema,
88
+ opts.includeDrafts,
89
+ usingJsonFile,
90
+ projectRoot
91
+ );
52
92
  if (ctx.watcher && !watcherAttached) {
53
93
  watcherAttached = true;
54
94
  const root = opts.projectRoot ?? process.cwd();
55
- const watchDir = join(root, ".cancia", "lists", opts.list, opts.site);
95
+ const watchDir = join2(root, ".cancia", "lists", opts.list, opts.site);
56
96
  ctx.watcher.add(watchDir);
57
97
  const scheduleSync = () => {
58
98
  if (pendingTimer) clearTimeout(pendingTimer);
@@ -66,7 +106,9 @@ function canciaLoader(opts) {
66
106
  opts.list,
67
107
  opts.site,
68
108
  opts.schema,
69
- opts.includeDrafts
109
+ opts.includeDrafts,
110
+ usingJsonFile,
111
+ projectRoot
70
112
  ).catch((err) => {
71
113
  latestCtx.logger.error(`cancia: resync failed \u2014 ${err.message}`);
72
114
  })
@@ -3,6 +3,15 @@ var SITE_RE = /^[a-z0-9][a-z0-9._-]*$/i;
3
3
  function isValidSite(site) {
4
4
  return SITE_RE.test(site);
5
5
  }
6
+ function detectFileType(buf) {
7
+ if ([37, 80, 68, 70, 45].every((b, i) => buf[i] === b)) {
8
+ return { mime: "application/pdf", ext: "pdf" };
9
+ }
10
+ return null;
11
+ }
12
+ function detectUploadType(buf) {
13
+ return detectImageType(buf) ?? detectFileType(buf);
14
+ }
6
15
  function detectImageType(buf) {
7
16
  const startsWith = (sig, offset = 0) => sig.every((b, i) => buf[offset + i] === b);
8
17
  if (startsWith([255, 216, 255])) return { mime: "image/jpeg", ext: "jpg" };
@@ -15,5 +24,5 @@ function detectImageType(buf) {
15
24
 
16
25
  export {
17
26
  isValidSite,
18
- detectImageType
27
+ detectUploadType
19
28
  };