@redeemid/emdash 0.0.18 → 0.0.19
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 +1 -1
- package/docs/developer-api.md +1 -1
- package/package.json +1 -1
- package/src/files.ts +39 -1
- package/src/guard.ts +10 -3
- package/src/log.ts +10 -3
- package/src/routes/callback.ts +2 -1
- package/src/routes/webhook.ts +20 -2
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
|
|
|
7
7
|
|
|
8
8
|
Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
|
|
9
9
|
|
|
10
|
-
**Status: 0.0.
|
|
10
|
+
**Status: 0.0.19, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
|
|
11
11
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
package/docs/developer-api.md
CHANGED
|
@@ -47,7 +47,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
47
47
|
- `api/state` (GET): the visitor's own state.
|
|
48
48
|
|
|
49
49
|
```json
|
|
50
|
-
{ "apiVersion": "1.0.0", "version": "0.0.
|
|
50
|
+
{ "apiVersion": "1.0.0", "version": "0.0.19", "signedIn": true, "linked": true,
|
|
51
51
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
52
52
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
53
53
|
```
|
package/package.json
CHANGED
package/src/files.ts
CHANGED
|
@@ -118,7 +118,8 @@ export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null
|
|
|
118
118
|
if (!config?.files?.length) return { protected: false };
|
|
119
119
|
const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
|
|
120
120
|
const db = locals.emdash?.db;
|
|
121
|
-
|
|
121
|
+
// No database to look the file up in: the caller fails closed.
|
|
122
|
+
if (!db) throw new Error("no database for the file check");
|
|
122
123
|
const media = await mediaByKey(db, key);
|
|
123
124
|
const rule = ruleFor(config, media?.folder ?? null);
|
|
124
125
|
if (!media || !rule) return { protected: false };
|
|
@@ -162,12 +163,49 @@ export function keysFromPath(pathname: string): string[] | null {
|
|
|
162
163
|
break;
|
|
163
164
|
}
|
|
164
165
|
out.push(decoded);
|
|
166
|
+
// The file route drops slashes around the key, so the check does too.
|
|
167
|
+
const trimmed = decoded.replace(/^\/+|\/+$/g, "");
|
|
168
|
+
if (trimmed && trimmed !== decoded) out.push(trimmed);
|
|
165
169
|
if (decoded === candidate) break;
|
|
166
170
|
candidate = decoded;
|
|
167
171
|
}
|
|
168
172
|
return [...new Set(out)];
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/** Astro's image endpoint, which reads the file its `href` names and answers with it. */
|
|
176
|
+
export const IMAGE_PATH = "/_image";
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The storage keys a request could reach: a file URL's own, or those of the
|
|
180
|
+
* file an image-endpoint request asks for by `href`. Null when it names none.
|
|
181
|
+
*/
|
|
182
|
+
export function keysFromUrl(url: URL): string[] | null {
|
|
183
|
+
const direct = keysFromPath(url.pathname);
|
|
184
|
+
if (direct) return direct;
|
|
185
|
+
if (url.pathname.replace(/\/+$/, "") !== IMAGE_PATH) return null;
|
|
186
|
+
const out: string[] = [];
|
|
187
|
+
for (const href of url.searchParams.getAll("href")) {
|
|
188
|
+
let candidate = href;
|
|
189
|
+
for (let layer = 0; layer < 4; layer++) {
|
|
190
|
+
try {
|
|
191
|
+
const keys = keysFromPath(new URL(candidate, url.origin).pathname);
|
|
192
|
+
if (keys) out.push(...keys);
|
|
193
|
+
} catch {
|
|
194
|
+
// Not a URL: nothing to check.
|
|
195
|
+
}
|
|
196
|
+
let decoded: string;
|
|
197
|
+
try {
|
|
198
|
+
decoded = decodeURIComponent(candidate);
|
|
199
|
+
} catch {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (decoded === candidate) break;
|
|
203
|
+
candidate = decoded;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out.length ? [...new Set(out)] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
171
209
|
/** Decide a download that may name several keys: the first protected one decides. */
|
|
172
210
|
export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
|
|
173
211
|
for (const key of keys) {
|
package/src/guard.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { defineMiddleware } from "astro:middleware";
|
|
|
23
23
|
|
|
24
24
|
import { signInUrl } from "./access.js";
|
|
25
25
|
import { readCart } from "./cart.js";
|
|
26
|
-
import {
|
|
26
|
+
import { keysFromUrl, pathAccess, refusal } from "./files.js";
|
|
27
27
|
import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
|
|
28
28
|
import { shareConfig, siteConfig } from "./site-config.js";
|
|
29
29
|
|
|
@@ -49,7 +49,14 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
|
|
|
49
49
|
*/
|
|
50
50
|
async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
|
|
51
51
|
const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
|
|
52
|
-
if (
|
|
52
|
+
if (!locals.emdash?.db) {
|
|
53
|
+
// EmDash leaves the database off some public requests: fetch it, so the check can run.
|
|
54
|
+
const { getDb } = await import("emdash/runtime");
|
|
55
|
+
const next = { ...locals, emdash: { ...(locals.emdash ?? {}), db: await getDb() } };
|
|
56
|
+
shareConfig(locals, next);
|
|
57
|
+
return withUser({ ...context, locals: next });
|
|
58
|
+
}
|
|
59
|
+
if (locals.user) return context;
|
|
53
60
|
const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
|
|
54
61
|
if (!sessionUser?.id) return context;
|
|
55
62
|
const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
|
|
@@ -80,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
80
87
|
const config = await siteConfig(locals);
|
|
81
88
|
|
|
82
89
|
// Protected files: checked for everyone, on every download.
|
|
83
|
-
const keys = config?.files?.length ?
|
|
90
|
+
const keys = config?.files?.length ? keysFromUrl(url) : null;
|
|
84
91
|
if (keys) {
|
|
85
92
|
let verdict;
|
|
86
93
|
try {
|
package/src/log.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { StorageCollection } from "emdash";
|
|
11
11
|
|
|
12
12
|
export const LOG_DAYS = 30;
|
|
13
|
+
const PRUNE_PAGES = 5;
|
|
13
14
|
|
|
14
15
|
export type LogOrigin = "webhook" | "member" | "staff" | "agent" | "cron" | "site";
|
|
15
16
|
|
|
@@ -90,7 +91,13 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
|
|
|
90
91
|
/** Delete entries (and other dated records) older than `days`, a page at a time. */
|
|
91
92
|
export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
|
|
92
93
|
const before = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
let gone = 0;
|
|
95
|
+
// A few pages a run: enough to keep up with a busy site, few enough queries for one request.
|
|
96
|
+
for (let page = 0; page < PRUNE_PAGES; page++) {
|
|
97
|
+
const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
|
|
98
|
+
if (items.length === 0) break;
|
|
99
|
+
gone += await store.deleteMany(items.map((i) => i.id));
|
|
100
|
+
if (items.length < 100) break;
|
|
101
|
+
}
|
|
102
|
+
return gone;
|
|
96
103
|
}
|
package/src/routes/callback.ts
CHANGED
|
@@ -48,7 +48,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
48
48
|
const state = url.searchParams.get("state");
|
|
49
49
|
const code = url.searchParams.get("code");
|
|
50
50
|
if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
|
|
51
|
-
|
|
51
|
+
// A request with no sign-in behind it (a crawler, a probe) isn't worth an entry.
|
|
52
|
+
if (flow) await refused("sign_in_expired", "warn");
|
|
52
53
|
return fail(redirect, returnTo, "sign_in_expired");
|
|
53
54
|
}
|
|
54
55
|
|
package/src/routes/webhook.ts
CHANGED
|
@@ -19,6 +19,11 @@ import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
|
|
|
19
19
|
export const prerender = false;
|
|
20
20
|
|
|
21
21
|
const MAX_BYTES = 1024 * 1024;
|
|
22
|
+
/** A delivery still "pending" after this long never finished. */
|
|
23
|
+
const STALE_PENDING_MS = 60 * 1000;
|
|
24
|
+
/** At most one "Bad signature" entry per this long, so junk requests can't fill App events. */
|
|
25
|
+
const REJECT_LOG_MS = 60 * 1000;
|
|
26
|
+
let lastRejectLogged = 0;
|
|
22
27
|
|
|
23
28
|
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
29
|
const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
|
|
@@ -31,7 +36,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
31
36
|
const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
|
|
32
37
|
const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
|
|
33
38
|
if (!ok) {
|
|
34
|
-
|
|
39
|
+
if (Date.now() - lastRejectLogged > REJECT_LOG_MS) {
|
|
40
|
+
lastRejectLogged = Date.now();
|
|
41
|
+
await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
|
|
42
|
+
}
|
|
35
43
|
return new Response("Bad signature", { status: 401 });
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -51,7 +59,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
51
59
|
const customerId = topic === "shop/redact" ? null : customerOf(payload);
|
|
52
60
|
const receivedAt = new Date().toISOString();
|
|
53
61
|
if (delivery) {
|
|
54
|
-
const
|
|
62
|
+
const claim = () => s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
|
|
63
|
+
let claimed = await claim();
|
|
64
|
+
if (!claimed.applied) {
|
|
65
|
+
// A delivery left "pending" was cut off before it finished (a
|
|
66
|
+
// timeout, a crash): let Shopify's retry run it again.
|
|
67
|
+
const earlier = await s.deliveries.get(delivery);
|
|
68
|
+
if (earlier?.outcome === "pending" && Date.now() - Date.parse(earlier.receivedAt) > STALE_PENDING_MS) {
|
|
69
|
+
await s.deliveries.delete(delivery);
|
|
70
|
+
claimed = await claim();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
55
73
|
if (!claimed.applied) {
|
|
56
74
|
await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
|
|
57
75
|
return new Response("Duplicate", { status: 200 });
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.19";
|