@cancia/astro 0.11.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.
- package/dist/chunk-4PYVIIOO.js +25 -0
- package/dist/{chunk-GD37XVES.js → chunk-5R3I5RLT.js} +6 -8
- package/dist/chunk-6GUOGDWO.js +172 -0
- package/dist/chunk-DEI3FBNY.js +65 -0
- package/dist/{chunk-T2GDBQJ7.js → chunk-DOFLZ7KG.js} +5 -6
- package/dist/{chunk-GJIX7MWC.js → chunk-ECPYHIAW.js} +9 -1
- package/dist/{chunk-TUZNTXZR.js → chunk-R7VA3Z5N.js} +8 -2
- package/dist/{chunk-ZPXMPLQU.js → chunk-VF3EXXEM.js} +130 -7
- package/dist/chunk-XZCXWILA.js +145 -0
- package/dist/{upload-DwCGjXbz.d.ts → email-BRK4WAO2.d.ts} +9 -1
- package/dist/endpoints/auth-callback.d.ts +1 -0
- package/dist/endpoints/auth-callback.js +9 -0
- package/dist/endpoints/auth-logout.d.ts +1 -0
- package/dist/endpoints/auth-logout.js +9 -0
- package/dist/endpoints/auth-request.d.ts +18 -0
- package/dist/endpoints/auth-request.js +9 -0
- package/dist/endpoints/auth-session.d.ts +1 -0
- package/dist/endpoints/auth-session.js +9 -0
- package/dist/endpoints/auth.js +10 -2
- package/dist/endpoints/content.js +12 -12
- package/dist/endpoints/lists.js +4 -1
- package/dist/endpoints/publish.js +8 -6
- package/dist/endpoints/schemas.js +5 -2
- package/dist/endpoints/upload.js +7 -6
- package/dist/{git-backed-DtiH52EI.d.ts → git-backed-CwTFfUS4.d.ts} +2 -2
- package/dist/{github-client-Db0pIrqE.d.ts → github-client-Db5EPcSL.d.ts} +1 -1
- package/dist/index.d.ts +32 -6
- package/dist/index.js +117 -27
- package/dist/loader/index.d.ts +12 -2
- package/dist/loader/index.js +1 -1
- package/dist/runtime.d.ts +69 -3
- package/dist/runtime.js +3 -1
- package/dist/storage/index.d.ts +4 -4
- package/dist/{types-BMlLS-OS.d.ts → types-CKzfz5iu.d.ts} +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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);
|
|
@@ -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,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
guardRequest
|
|
3
|
+
} from "./chunk-4PYVIIOO.js";
|
|
1
4
|
import {
|
|
2
5
|
loadSchemas
|
|
3
6
|
} from "./chunk-VL6FO446.js";
|
|
@@ -14,12 +17,8 @@ function json(body, status = 200) {
|
|
|
14
17
|
}
|
|
15
18
|
function makeSchemasRoute(ctx) {
|
|
16
19
|
return async function schemasRoute(request) {
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -67,12 +67,18 @@ function canciaLoader(opts) {
|
|
|
67
67
|
async load(ctx) {
|
|
68
68
|
latestCtx = ctx;
|
|
69
69
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
70
|
+
const detectedDb = !opts.storage && !opts.db && existsSync(join2(projectRoot, "cancia.db"));
|
|
70
71
|
const storage = opts.storage ?? resolveReadStorage(
|
|
71
|
-
opts.db ? { kind: opts.db.kind === "sqlite" ? "sqlite-v2" : "json-file", dbPath: opts.db.path } : void 0,
|
|
72
|
+
opts.db ? { kind: opts.db.kind === "sqlite" ? "sqlite-v2" : "json-file", dbPath: opts.db.path } : detectedDb ? { kind: "sqlite-v2" } : void 0,
|
|
72
73
|
projectRoot
|
|
73
74
|
);
|
|
74
75
|
const { lists } = storage;
|
|
75
|
-
|
|
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;
|
|
76
82
|
await syncOnce(
|
|
77
83
|
ctx,
|
|
78
84
|
lists,
|
|
@@ -2,6 +2,9 @@ import {
|
|
|
2
2
|
detectUploadType,
|
|
3
3
|
isValidSite
|
|
4
4
|
} from "./chunk-RHFTQW4W.js";
|
|
5
|
+
import {
|
|
6
|
+
guardRequest
|
|
7
|
+
} from "./chunk-4PYVIIOO.js";
|
|
5
8
|
import {
|
|
6
9
|
createGitBackedAdapter
|
|
7
10
|
} from "./chunk-XINNLVZP.js";
|
|
@@ -11,6 +14,115 @@ import {
|
|
|
11
14
|
createSqliteAdapterV2
|
|
12
15
|
} from "./chunk-S5YLB6P3.js";
|
|
13
16
|
|
|
17
|
+
// src/auth/sqlite-store.ts
|
|
18
|
+
import { createRequire } from "module";
|
|
19
|
+
import { mkdirSync } from "fs";
|
|
20
|
+
import { dirname } from "path";
|
|
21
|
+
var require2 = createRequire(import.meta.url);
|
|
22
|
+
var connections = /* @__PURE__ */ new Map();
|
|
23
|
+
function openAuthDB(dbPath) {
|
|
24
|
+
const cached = connections.get(dbPath);
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
const Database = require2("better-sqlite3");
|
|
27
|
+
if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
|
|
28
|
+
const db = new Database(dbPath);
|
|
29
|
+
db.pragma("journal_mode = WAL");
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS editors (
|
|
32
|
+
site TEXT NOT NULL, email TEXT NOT NULL, last_seen_at TEXT,
|
|
33
|
+
PRIMARY KEY (site, email)
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
36
|
+
site TEXT NOT NULL, id TEXT NOT NULL, email TEXT NOT NULL,
|
|
37
|
+
created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
38
|
+
PRIMARY KEY (site, id)
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS login_tokens (
|
|
41
|
+
site TEXT NOT NULL, hash TEXT NOT NULL, email TEXT NOT NULL,
|
|
42
|
+
expires_at TEXT NOT NULL,
|
|
43
|
+
PRIMARY KEY (site, hash)
|
|
44
|
+
);
|
|
45
|
+
`);
|
|
46
|
+
connections.set(dbPath, db);
|
|
47
|
+
return db;
|
|
48
|
+
}
|
|
49
|
+
function createSqliteAuthStore(opts) {
|
|
50
|
+
const db = openAuthDB(opts.dbPath);
|
|
51
|
+
const insertEditor = db.prepare(
|
|
52
|
+
`INSERT INTO editors (site, email, last_seen_at) VALUES (?, ?, NULL)
|
|
53
|
+
ON CONFLICT(site, email) DO NOTHING`
|
|
54
|
+
);
|
|
55
|
+
const deleteOtherEditors = db.prepare("DELETE FROM editors WHERE site=?");
|
|
56
|
+
const selectEditor = db.prepare("SELECT email FROM editors WHERE site=? AND email=?");
|
|
57
|
+
const insertToken = db.prepare(
|
|
58
|
+
`INSERT INTO login_tokens (site, hash, email, expires_at) VALUES (?, ?, ?, ?)
|
|
59
|
+
ON CONFLICT(site, hash) DO NOTHING`
|
|
60
|
+
);
|
|
61
|
+
const selectToken = db.prepare(
|
|
62
|
+
"SELECT hash, email, expires_at FROM login_tokens WHERE site=? AND hash=?"
|
|
63
|
+
);
|
|
64
|
+
const deleteToken = db.prepare("DELETE FROM login_tokens WHERE site=? AND hash=?");
|
|
65
|
+
const insertSession = db.prepare(
|
|
66
|
+
"INSERT INTO sessions (site, id, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)"
|
|
67
|
+
);
|
|
68
|
+
const selectSession = db.prepare(
|
|
69
|
+
"SELECT id, email, created_at, expires_at FROM sessions WHERE site=? AND id=?"
|
|
70
|
+
);
|
|
71
|
+
const touchStmt = db.prepare("UPDATE sessions SET expires_at=? WHERE site=? AND id=?");
|
|
72
|
+
const deleteSessionStmt = db.prepare("DELETE FROM sessions WHERE site=? AND id=?");
|
|
73
|
+
const purgeTokens = db.prepare("DELETE FROM login_tokens WHERE site=? AND expires_at<=?");
|
|
74
|
+
const purgeSessions = db.prepare("DELETE FROM sessions WHERE site=? AND expires_at<=?");
|
|
75
|
+
const seedTx = db.transaction((site, emails) => {
|
|
76
|
+
deleteOtherEditors.run(site);
|
|
77
|
+
for (const email of emails) insertEditor.run(site, email);
|
|
78
|
+
});
|
|
79
|
+
const consumeTx = db.transaction((site, hash) => {
|
|
80
|
+
const row = selectToken.get(site, hash);
|
|
81
|
+
if (!row) return void 0;
|
|
82
|
+
deleteToken.run(site, hash);
|
|
83
|
+
return row;
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
async seedEditors(site, emails) {
|
|
87
|
+
seedTx(site, emails);
|
|
88
|
+
},
|
|
89
|
+
async isEditor(site, email) {
|
|
90
|
+
return selectEditor.get(site, email) !== void 0;
|
|
91
|
+
},
|
|
92
|
+
async createLoginToken(site, rec) {
|
|
93
|
+
insertToken.run(site, rec.hash, rec.email, rec.expiresAt);
|
|
94
|
+
},
|
|
95
|
+
async consumeLoginToken(site, hash) {
|
|
96
|
+
const row = consumeTx(site, hash);
|
|
97
|
+
if (!row) return null;
|
|
98
|
+
return { hash: row.hash, email: row.email, expiresAt: row.expires_at };
|
|
99
|
+
},
|
|
100
|
+
async createSession(site, rec) {
|
|
101
|
+
insertSession.run(site, rec.id, rec.email, rec.createdAt, rec.expiresAt);
|
|
102
|
+
},
|
|
103
|
+
async getSession(site, id) {
|
|
104
|
+
const row = selectSession.get(site, id);
|
|
105
|
+
if (!row) return null;
|
|
106
|
+
return {
|
|
107
|
+
id: row.id,
|
|
108
|
+
email: row.email,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
expiresAt: row.expires_at
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
async touchSession(site, id, expiresAt) {
|
|
114
|
+
touchStmt.run(expiresAt, site, id);
|
|
115
|
+
},
|
|
116
|
+
async deleteSession(site, id) {
|
|
117
|
+
deleteSessionStmt.run(site, id);
|
|
118
|
+
},
|
|
119
|
+
async purgeExpired(site, now) {
|
|
120
|
+
purgeTokens.run(site, now);
|
|
121
|
+
purgeSessions.run(site, now);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
14
126
|
// src/routes/upload.ts
|
|
15
127
|
import { writeFile, mkdir } from "fs/promises";
|
|
16
128
|
import { join } from "path";
|
|
@@ -27,14 +139,10 @@ function makeLocalUploadHandler(opts) {
|
|
|
27
139
|
return `${publicUrlBase}/${site}/${name}`;
|
|
28
140
|
};
|
|
29
141
|
}
|
|
30
|
-
function makeUploadRoute(uploadHandler, secret, maxMB = 10) {
|
|
142
|
+
function makeUploadRoute(uploadHandler, secret, maxMB = 10, auth) {
|
|
31
143
|
return async ({ request }) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (token !== secret) {
|
|
35
|
-
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
36
|
-
}
|
|
37
|
-
}
|
|
144
|
+
const deny = (await guardRequest(request, { secret, auth })).deny;
|
|
145
|
+
if (deny) return deny;
|
|
38
146
|
const form = await request.formData().catch(() => null);
|
|
39
147
|
const file = form?.get("file");
|
|
40
148
|
const site = form?.get("site");
|
|
@@ -188,6 +296,18 @@ function buildRuntimeFromBaked(baked) {
|
|
|
188
296
|
const storageV2 = buildStorageV2(baked, projectRoot);
|
|
189
297
|
const storage = storageV2 ? storageV2.kv : lazyJsonFileAdapter(projectRoot);
|
|
190
298
|
const uploadHandler = buildUploadHandler(baked, projectRoot);
|
|
299
|
+
const site = baked.site ?? "default";
|
|
300
|
+
const auth = baked.editors?.length ? {
|
|
301
|
+
store: createSqliteAuthStore({
|
|
302
|
+
dbPath: baked.authDbPath ? resolveDbPath(baked.authDbPath, projectRoot) : `${projectRoot}/cancia.db`
|
|
303
|
+
}),
|
|
304
|
+
site
|
|
305
|
+
} : void 0;
|
|
306
|
+
if (auth) {
|
|
307
|
+
auth.store.seedEditors(site, baked.editors.map((e) => e.trim().toLowerCase())).catch((err) => {
|
|
308
|
+
console.error(`[cancia] could not seed editors: ${err?.message ?? err}`);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
191
311
|
return {
|
|
192
312
|
storage,
|
|
193
313
|
storageV2,
|
|
@@ -196,6 +316,8 @@ function buildRuntimeFromBaked(baked) {
|
|
|
196
316
|
defaultLocale: baked.defaultLocale,
|
|
197
317
|
locales: baked.locales,
|
|
198
318
|
secret,
|
|
319
|
+
site,
|
|
320
|
+
auth,
|
|
199
321
|
uploadHandler,
|
|
200
322
|
deployHook,
|
|
201
323
|
deployHookToken,
|
|
@@ -269,6 +391,7 @@ export {
|
|
|
269
391
|
makeLocalUploadHandler,
|
|
270
392
|
makeUploadRoute,
|
|
271
393
|
makeR2UploadHandler,
|
|
394
|
+
createSqliteAuthStore,
|
|
272
395
|
setBakedConfig,
|
|
273
396
|
setCanciaRuntime,
|
|
274
397
|
__resetRuntimeForTests,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRateLimiter,
|
|
3
|
+
getIp,
|
|
4
|
+
isRateLimited,
|
|
5
|
+
recordFailure
|
|
6
|
+
} from "./chunk-ECPYHIAW.js";
|
|
7
|
+
import {
|
|
8
|
+
SESSION_TTL_MS,
|
|
9
|
+
clearSessionCookie,
|
|
10
|
+
isSecureRequest,
|
|
11
|
+
normaliseEmail,
|
|
12
|
+
readSessionCookie,
|
|
13
|
+
redeemLoginToken,
|
|
14
|
+
requestLoginLink,
|
|
15
|
+
revokeSession,
|
|
16
|
+
serialiseSessionCookie
|
|
17
|
+
} from "./chunk-6GUOGDWO.js";
|
|
18
|
+
|
|
19
|
+
// src/auth/email.ts
|
|
20
|
+
var LOOPS_API_URL = "https://app.loops.so/api/v1/transactional";
|
|
21
|
+
var consoleLoginEmail = async ({ to, url }) => {
|
|
22
|
+
console.log(
|
|
23
|
+
`
|
|
24
|
+
\x1B[36m[cancia]\x1B[0m login link for \x1B[1m${to}\x1B[0m:
|
|
25
|
+
${url}
|
|
26
|
+
`
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
var loopsLoginEmail = async ({ to, url }) => {
|
|
30
|
+
const apiKey = process.env.LOOPS_API_KEY;
|
|
31
|
+
const transactionalId = process.env.CANCIA_LOGIN_TID;
|
|
32
|
+
if (!apiKey || !transactionalId) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Loops login email needs LOOPS_API_KEY and CANCIA_LOGIN_TID in the server environment."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const res = await fetch(LOOPS_API_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
Authorization: `Bearer ${apiKey}`
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
email: to,
|
|
45
|
+
transactionalId,
|
|
46
|
+
dataVariables: { loginUrl: url }
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(`Loops login email failed (${res.status})`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function resolveLoginEmailSender(explicit) {
|
|
54
|
+
if (explicit) return explicit;
|
|
55
|
+
if (process.env.LOOPS_API_KEY && process.env.CANCIA_LOGIN_TID) return loopsLoginEmail;
|
|
56
|
+
return consoleLoginEmail;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/auth/handlers.ts
|
|
60
|
+
var json = (body, status, headers = {}) => new Response(JSON.stringify(body), {
|
|
61
|
+
status,
|
|
62
|
+
headers: { "Content-Type": "application/json", ...headers }
|
|
63
|
+
});
|
|
64
|
+
var NEUTRAL_ACK = { ok: true, message: "If that address can edit this site, a link is on its way." };
|
|
65
|
+
function looksLikeEmail(value) {
|
|
66
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 254;
|
|
67
|
+
}
|
|
68
|
+
async function handleLoginRequest(request, ctx, origin) {
|
|
69
|
+
const limiter = ctx.limiter ?? createRateLimiter();
|
|
70
|
+
const now = ctx.now ?? Date.now();
|
|
71
|
+
const ip = getIp(request);
|
|
72
|
+
if (isRateLimited(limiter, ip, now)) {
|
|
73
|
+
return json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
74
|
+
}
|
|
75
|
+
let email;
|
|
76
|
+
try {
|
|
77
|
+
const body = await request.json();
|
|
78
|
+
email = body?.email;
|
|
79
|
+
} catch {
|
|
80
|
+
return json({ error: "Invalid request body" }, 400);
|
|
81
|
+
}
|
|
82
|
+
if (!email || !looksLikeEmail(email.trim())) {
|
|
83
|
+
return json({ error: "Enter a valid email address." }, 400);
|
|
84
|
+
}
|
|
85
|
+
const normalised = normaliseEmail(email);
|
|
86
|
+
if (isRateLimited(limiter, `email:${normalised}`, now)) {
|
|
87
|
+
return json(NEUTRAL_ACK, 200);
|
|
88
|
+
}
|
|
89
|
+
recordFailure(limiter, ip, now);
|
|
90
|
+
recordFailure(limiter, `email:${normalised}`, now);
|
|
91
|
+
const { token } = await requestLoginLink(ctx.store, ctx.site, normalised, now);
|
|
92
|
+
if (token) {
|
|
93
|
+
const url = `${origin}/?cancia_login=${encodeURIComponent(token)}`;
|
|
94
|
+
void ctx.sendEmail({ to: normalised, url, site: ctx.site }).catch((err) => {
|
|
95
|
+
console.error(
|
|
96
|
+
`[cancia] failed to send login email: ${err instanceof Error ? err.message : String(err)}`
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
void ctx.store.purgeExpired(ctx.site, new Date(now).toISOString()).catch(() => {
|
|
101
|
+
});
|
|
102
|
+
return json(NEUTRAL_ACK, 200);
|
|
103
|
+
}
|
|
104
|
+
async function handleLoginCallback(request, ctx) {
|
|
105
|
+
const now = ctx.now ?? Date.now();
|
|
106
|
+
let token;
|
|
107
|
+
try {
|
|
108
|
+
const body = await request.json();
|
|
109
|
+
token = body?.token;
|
|
110
|
+
} catch {
|
|
111
|
+
return json({ error: "Invalid request body" }, 400);
|
|
112
|
+
}
|
|
113
|
+
const result = await redeemLoginToken(ctx.store, ctx.site, token ?? "", now);
|
|
114
|
+
if (!result.sessionToken) {
|
|
115
|
+
return json({ error: "That link is invalid or has expired. Request a new one." }, 401);
|
|
116
|
+
}
|
|
117
|
+
return json(
|
|
118
|
+
{ ok: true, email: result.email },
|
|
119
|
+
200,
|
|
120
|
+
{
|
|
121
|
+
"Set-Cookie": serialiseSessionCookie(result.sessionToken, {
|
|
122
|
+
secure: isSecureRequest(request),
|
|
123
|
+
maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1e3)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
async function handleLogout(request, ctx) {
|
|
129
|
+
await revokeSession(ctx.store, ctx.site, readSessionCookie(request));
|
|
130
|
+
return json({ ok: true }, 200, {
|
|
131
|
+
"Set-Cookie": clearSessionCookie({ secure: isSecureRequest(request) })
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function handleSessionCheck(request, ctx, authorize) {
|
|
135
|
+
const verdict = await authorize();
|
|
136
|
+
return json({ authenticated: verdict.ok, email: verdict.email }, 200);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export {
|
|
140
|
+
resolveLoginEmailSender,
|
|
141
|
+
handleLoginRequest,
|
|
142
|
+
handleLoginCallback,
|
|
143
|
+
handleLogout,
|
|
144
|
+
handleSessionCheck
|
|
145
|
+
};
|