@redeemid/emdash 0.0.9 → 0.0.11
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 +138 -2
- package/bin/redeemid-emdash.mjs +144 -0
- package/docs/shopify-setup.md +4 -2
- package/package.json +8 -2
- package/src/access.ts +2 -2
- package/src/agents.ts +179 -0
- package/src/blocks/ContributionBlock.astro +1 -1
- package/src/blocks/PlanBlock.astro +1 -1
- package/src/blocks/PreferencesBlock.astro +12 -0
- package/src/blocks/index.ts +3 -0
- package/src/blocks-names.ts +27 -0
- package/src/components/AccessBadge.astro +10 -6
- package/src/components/Account.astro +5 -4
- package/src/components/Cart.astro +4 -1
- package/src/components/CartView.astro +14 -13
- package/src/components/Collection.astro +2 -1
- package/src/components/Content.astro +25 -10
- package/src/components/Contribution.astro +4 -3
- package/src/components/LockedPanel.astro +35 -9
- package/src/components/MemberCard.astro +15 -14
- package/src/components/Notices.astro +12 -2
- package/src/components/Plan.astro +3 -2
- package/src/components/Preferences.astro +102 -0
- package/src/components/Product.astro +6 -5
- package/src/components/YourData.astro +42 -0
- package/src/config.ts +38 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +3 -2
- package/src/files.ts +196 -0
- package/src/guard.ts +43 -2
- package/src/index.ts +2 -0
- package/src/plugin.ts +169 -4
- package/src/preferences.ts +298 -0
- package/src/privacy.ts +148 -0
- package/src/routes/preferences.ts +82 -0
- package/src/routes/privacy.ts +59 -0
- package/src/routes/webhook.ts +1 -1
- package/src/staff.ts +92 -0
- package/src/storage.ts +17 -0
- package/src/text.ts +185 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +17 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The member's preferences form: every field in `preferences`, read from
|
|
4
|
+
* their Shopify customer, with a control for each type. Fields set to "view"
|
|
5
|
+
* are shown, not editable. Works without JavaScript: it posts to the
|
|
6
|
+
* preferences route and comes back with a notice. Visitors and sites without
|
|
7
|
+
* preferences get nothing (or the "signed-out" slot).
|
|
8
|
+
*
|
|
9
|
+
* <Preferences title="Your preferences" />
|
|
10
|
+
*/
|
|
11
|
+
import { viewer } from "../access.js";
|
|
12
|
+
import { preferencesConfigured, readPreferences, type PreferenceField } from "../preferences.js";
|
|
13
|
+
import { siteConfig } from "../site-config.js";
|
|
14
|
+
import { text } from "../text.js";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
title?: string;
|
|
18
|
+
/** Heading level for the title. */
|
|
19
|
+
heading?: "h2" | "h3" | "h4";
|
|
20
|
+
label?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
24
|
+
const { title = text(config, "Your preferences"), heading = "h2", label = text(config, "Save preferences") } = Astro.props;
|
|
25
|
+
const v = await viewer(Astro);
|
|
26
|
+
const m = v.member;
|
|
27
|
+
let fields: PreferenceField[] = [];
|
|
28
|
+
let failed = false;
|
|
29
|
+
if (config && preferencesConfigured(config) && m && m.status !== "disabled") {
|
|
30
|
+
try {
|
|
31
|
+
fields = await readPreferences(config, m.customerId);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
failed = true;
|
|
34
|
+
console.error("[redeemid] preferences not read:", (e as Error).message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const here = Astro.url.pathname + Astro.url.search.replace(/[?&]redeemid_notice=[^&]*/, "").replace(/^&/, "?");
|
|
38
|
+
const H = heading;
|
|
39
|
+
const id = (key: string) => `redeemid-pref-${key.replace(/[^\w-]/g, "-")}`;
|
|
40
|
+
const show = (f: PreferenceField) =>
|
|
41
|
+
f.value === null || (Array.isArray(f.value) && f.value.length === 0) ? text(config, "Not set") : Array.isArray(f.value) ? f.value.join(", ") : f.type === "boolean" ? text(config, f.value ? "Yes" : "No") : String(f.value);
|
|
42
|
+
const editable = fields.filter((f) => f.editable);
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
{v.signedIn && (fields.length > 0 || failed) ? (
|
|
46
|
+
<section class="redeemid-preferences">
|
|
47
|
+
<H class="redeemid-preferences__title">{title}</H>
|
|
48
|
+
{failed ? (
|
|
49
|
+
<p>{text(config, "Your preferences can't be loaded right now. Try again in a few minutes.")}</p>
|
|
50
|
+
) : (
|
|
51
|
+
<form method="post" action="/_emdash/api/auth/redeemid/preferences">
|
|
52
|
+
<input type="hidden" name="return" value={here} />
|
|
53
|
+
{fields.map((f) => (
|
|
54
|
+
<div class="redeemid-preferences__field" data-redeemid-pref={f.key}>
|
|
55
|
+
{f.editable && <input type="hidden" name={`present:${f.key}`} value="1" />}
|
|
56
|
+
{!f.editable ? (
|
|
57
|
+
<p>
|
|
58
|
+
<strong>{f.name}:</strong> {show(f)}
|
|
59
|
+
</p>
|
|
60
|
+
) : f.type === "boolean" ? (
|
|
61
|
+
<label>
|
|
62
|
+
<input type="checkbox" name={`pref:${f.key}`} value="true" checked={f.value === true} /> {f.name}
|
|
63
|
+
</label>
|
|
64
|
+
) : f.type === "list.single_line_text_field" && f.choices ? (
|
|
65
|
+
<fieldset>
|
|
66
|
+
<legend>{f.name}</legend>
|
|
67
|
+
{f.choices.map((c) => (
|
|
68
|
+
<label>
|
|
69
|
+
<input type="checkbox" name={`pref:${f.key}`} value={c} checked={Array.isArray(f.value) && f.value.includes(c)} /> {c}
|
|
70
|
+
</label>
|
|
71
|
+
))}
|
|
72
|
+
</fieldset>
|
|
73
|
+
) : (
|
|
74
|
+
<>
|
|
75
|
+
<label for={id(f.key)}>{f.name}</label>
|
|
76
|
+
{f.choices ? (
|
|
77
|
+
<select id={id(f.key)} name={`pref:${f.key}`}>
|
|
78
|
+
<option value="">{text(config, "Not set")}</option>
|
|
79
|
+
{f.choices.map((c) => <option value={c} selected={f.value === c}>{c}</option>)}
|
|
80
|
+
</select>
|
|
81
|
+
) : f.type === "multi_line_text_field" || f.type === "list.single_line_text_field" ? (
|
|
82
|
+
<textarea id={id(f.key)} name={`pref:${f.key}`} rows="3" maxlength={f.max && f.type === "multi_line_text_field" ? Number(f.max) : undefined}>{Array.isArray(f.value) ? f.value.join("\n") : (f.value ?? "")}</textarea>
|
|
83
|
+
) : f.type === "number_integer" || f.type === "number_decimal" ? (
|
|
84
|
+
<input id={id(f.key)} type="number" name={`pref:${f.key}`} step={f.type === "number_integer" ? "1" : "any"} min={f.min ?? undefined} max={f.max ?? undefined} value={f.value === null ? "" : String(f.value)} />
|
|
85
|
+
) : f.type === "date" ? (
|
|
86
|
+
<input id={id(f.key)} type="date" name={`pref:${f.key}`} min={f.min?.slice(0, 10)} max={f.max?.slice(0, 10)} value={typeof f.value === "string" ? f.value : ""} />
|
|
87
|
+
) : (
|
|
88
|
+
<input id={id(f.key)} type="text" name={`pref:${f.key}`} maxlength={f.max ? Number(f.max) : undefined} value={typeof f.value === "string" ? f.value : ""} />
|
|
89
|
+
)}
|
|
90
|
+
{f.type === "list.single_line_text_field" && !f.choices && <small>{text(config, "One per line.")}</small>}
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
{f.description && f.editable && <small class="redeemid-preferences__help">{f.description}</small>}
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
{editable.length > 0 && <button type="submit">{label}</button>}
|
|
97
|
+
</form>
|
|
98
|
+
)}
|
|
99
|
+
</section>
|
|
100
|
+
) : (
|
|
101
|
+
!v.signedIn && <slot name="signed-out" />
|
|
102
|
+
)}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { siteConfig } from "../site-config.js";
|
|
11
11
|
import { money, product as loadProduct, type Product } from "../storefront.js";
|
|
12
|
+
import { text } from "../text.js";
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
15
|
handle: string;
|
|
@@ -20,8 +21,8 @@ interface Props {
|
|
|
20
21
|
buyLabel?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
|
|
24
24
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
25
|
+
const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
|
|
25
26
|
let p: Product | null = null;
|
|
26
27
|
if (config) {
|
|
27
28
|
try {
|
|
@@ -50,11 +51,11 @@ const id = `redeemid-product-${handle}`;
|
|
|
50
51
|
<input type="hidden" name="return" value={here} />
|
|
51
52
|
{p.variants.length > 1 ? (
|
|
52
53
|
<p>
|
|
53
|
-
<label for={`${id}-variant`}>Option</label>
|
|
54
|
+
<label for={`${id}-variant`}>{text(config, "Option")}</label>
|
|
54
55
|
<select id={`${id}-variant`} name="variant">
|
|
55
56
|
{p.variants.map((v) => (
|
|
56
57
|
<option value={v.id} disabled={!v.availableForSale}>
|
|
57
|
-
{v.
|
|
58
|
+
{text(config, v.availableForSale ? "{option} ({price})" : "{option} ({price}) sold out", { option: v.title, price: money(v.price) })}
|
|
58
59
|
</option>
|
|
59
60
|
))}
|
|
60
61
|
</select>
|
|
@@ -63,14 +64,14 @@ const id = `redeemid-product-${handle}`;
|
|
|
63
64
|
<input type="hidden" name="variant" value={first?.id} />
|
|
64
65
|
)}
|
|
65
66
|
<p>
|
|
66
|
-
<label for={`${id}-quantity`}>Quantity</label>
|
|
67
|
+
<label for={`${id}-quantity`}>{text(config, "Quantity")}</label>
|
|
67
68
|
<input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
|
|
68
69
|
</p>
|
|
69
70
|
<button type="submit" name="then" value="back">{addLabel}</button>
|
|
70
71
|
<button type="submit" name="then" value="checkout">{buyLabel}</button>
|
|
71
72
|
</form>
|
|
72
73
|
) : (
|
|
73
|
-
<p class="redeemid-product__unavailable">Sold out
|
|
74
|
+
<p class="redeemid-product__unavailable">{text(config, "Sold out.")}</p>
|
|
74
75
|
)}
|
|
75
76
|
</div>
|
|
76
77
|
) : (
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The member's own data, for an account page: "Download my data" (everything
|
|
4
|
+
* this site keeps about them, as JSON) and "Delete my data", which erases it
|
|
5
|
+
* after they tick a box and signs them out. Their store account and orders
|
|
6
|
+
* stay; the text says so. Visitors get nothing.
|
|
7
|
+
*
|
|
8
|
+
* <YourData />
|
|
9
|
+
*/
|
|
10
|
+
import { viewer } from "../access.js";
|
|
11
|
+
import { siteConfig } from "../site-config.js";
|
|
12
|
+
import { text } from "../text.js";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
title?: string;
|
|
16
|
+
heading?: "h2" | "h3" | "h4";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
20
|
+
const { title = text(config, "Your data"), heading = "h2" } = Astro.props;
|
|
21
|
+
const v = await viewer(Astro);
|
|
22
|
+
const route = "/_emdash/api/auth/redeemid/privacy";
|
|
23
|
+
const here = Astro.url.pathname;
|
|
24
|
+
const H = heading;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
{v.signedIn && v.member && (
|
|
28
|
+
<section class="redeemid-data">
|
|
29
|
+
<H class="redeemid-data__title">{title}</H>
|
|
30
|
+
<p>
|
|
31
|
+
<a href={`${route}?return=${encodeURIComponent(here)}`} download>{text(config, "Download my data")}</a>
|
|
32
|
+
</p>
|
|
33
|
+
<form method="post" action={route} class="redeemid-data__erase">
|
|
34
|
+
<input type="hidden" name="return" value={here} />
|
|
35
|
+
<p>{text(config, "Deleting removes what this site keeps about you and signs you out. Your store account and orders stay; ask the store to delete those.")}</p>
|
|
36
|
+
<label>
|
|
37
|
+
<input type="checkbox" name="confirm" value="yes" required /> {text(config, "I understand")}
|
|
38
|
+
</label>
|
|
39
|
+
<button type="submit">{text(config, "Delete my data")}</button>
|
|
40
|
+
</form>
|
|
41
|
+
</section>
|
|
42
|
+
)}
|
package/src/config.ts
CHANGED
|
@@ -72,10 +72,29 @@ export interface FeatureConfig {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/** Where to send signed member events (see events.ts). The signing secret is REDEEMID_EVENTS_SECRET. */
|
|
75
|
+
/** A media folder whose files only some readers may download. Any of the given ways in is enough. */
|
|
76
|
+
export interface FileRule {
|
|
77
|
+
/** The media folder's name in the EmDash media library. */
|
|
78
|
+
folder: string;
|
|
79
|
+
/** Members holding any of these levels. An empty list means any signed-in member. */
|
|
80
|
+
levels?: string[];
|
|
81
|
+
/** Members holding any of these features. */
|
|
82
|
+
features?: string[];
|
|
83
|
+
/** Whoever can read a published entry that uses the file (its rule, drip and the levels picked in its body). */
|
|
84
|
+
entries?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PreferenceConfig {
|
|
88
|
+
/** "namespace.key" of a customer metafield definition. */
|
|
89
|
+
key: string;
|
|
90
|
+
/** "edit" (default): members can change it. "view": members only see it. */
|
|
91
|
+
access?: "view" | "edit";
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
export interface EventsConfig {
|
|
76
95
|
url: string;
|
|
77
96
|
/** Send only these events. All of them when left out. */
|
|
78
|
-
only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted">;
|
|
97
|
+
only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased">;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
export interface MeterConfig {
|
|
@@ -100,6 +119,24 @@ export interface RedeemIDConfig {
|
|
|
100
119
|
meter?: MeterConfig;
|
|
101
120
|
features?: FeatureConfig[];
|
|
102
121
|
events?: EventsConfig;
|
|
122
|
+
/**
|
|
123
|
+
* Customer metafields members may see or change, in this order, by
|
|
124
|
+
* "namespace.key". Define them in Shopify (Settings → Custom data →
|
|
125
|
+
* Customers). Needs the Admin API app (staff tools) for reading and saving.
|
|
126
|
+
*/
|
|
127
|
+
preferences?: PreferenceConfig[];
|
|
128
|
+
/**
|
|
129
|
+
* AI agents (EmDash's MCP server) and the command line. Read-only tools are
|
|
130
|
+
* always declared; `actions: true` also offers resync and tag edits.
|
|
131
|
+
*/
|
|
132
|
+
agents?: { actions?: boolean };
|
|
133
|
+
/** Protected files, by media folder. See FileRule. */
|
|
134
|
+
files?: FileRule[];
|
|
135
|
+
/**
|
|
136
|
+
* Replacement text, by each string's original English, e.g.
|
|
137
|
+
* { "Sign in": "Log in" }. One string at a time; see text.ts for the list.
|
|
138
|
+
*/
|
|
139
|
+
text?: Record<string, string>;
|
|
103
140
|
/** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
|
|
104
141
|
join?: { href: string; label?: string };
|
|
105
142
|
/**
|
package/src/descriptor.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const PAGES = [
|
|
|
12
12
|
{ path: "/", label: "Members", icon: "users" },
|
|
13
13
|
{ path: "/status", label: "Status", icon: "pulse" },
|
|
14
14
|
{ path: "/test", label: "Access test", icon: "search" },
|
|
15
|
+
{ path: "/privacy", label: "Privacy", icon: "lock" },
|
|
15
16
|
];
|
|
16
17
|
export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
|
|
17
18
|
|
package/src/events.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
|
|
8
8
|
* X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
|
|
9
|
-
* member.signed_in, member.levels_changed, meter.used, meter.exhausted
|
|
9
|
+
* member.signed_in, member.levels_changed, meter.used, meter.exhausted,
|
|
10
|
+
* preferences.saved.
|
|
10
11
|
* Sending never blocks or breaks the page: failures are logged and dropped.
|
|
11
12
|
* No event carries an email address or a name; match people by `customerId`.
|
|
12
13
|
*/
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
import { env, ENV, type RedeemIDConfig } from "./config.js";
|
|
15
16
|
import { USER_AGENT } from "./shopify.js";
|
|
16
17
|
|
|
17
|
-
export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted";
|
|
18
|
+
export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
|
|
18
19
|
|
|
19
20
|
const TIMEOUT_MS = 3000;
|
|
20
21
|
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protected files: uploads in a media folder named in `files` are served only
|
|
3
|
+
* to readers the folder's rule lets in, checked on every download. EmDash
|
|
4
|
+
* serves uploads at /_emdash/api/media/file/<storage key>; the guard
|
|
5
|
+
* middleware calls fileAccess() before EmDash answers, refuses readers
|
|
6
|
+
* without access, and marks allowed downloads private so no cache keeps them.
|
|
7
|
+
*
|
|
8
|
+
* files: [
|
|
9
|
+
* { folder: "Gold downloads", levels: ["gold"] },
|
|
10
|
+
* { folder: "Member downloads", levels: [] }, // any signed-in member
|
|
11
|
+
* { folder: "Supporter extras", features: ["supporter"] },
|
|
12
|
+
* { folder: "Story files", entries: true }, // whoever can read an entry using it
|
|
13
|
+
* ]
|
|
14
|
+
*
|
|
15
|
+
* Staff (any EmDash role above Subscriber) can always download. Folder names
|
|
16
|
+
* match case-insensitively. Access still comes only from Shopify tags.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
|
|
20
|
+
import { holdsItem, ITEM_BLOCK, PAYWALL_BLOCK, sections, soldItem, splitAtPaywall } from "./blocks-names.js";
|
|
21
|
+
import type { FileRule, RedeemIDConfig } from "./config.js";
|
|
22
|
+
import { featuresFor } from "./features.js";
|
|
23
|
+
import { text } from "./text.js";
|
|
24
|
+
|
|
25
|
+
export const FILE_PATH = "/_emdash/api/media/file/";
|
|
26
|
+
const SUBSCRIBER = 10;
|
|
27
|
+
|
|
28
|
+
export type FileVerdict = { protected: false } | { protected: true; allowed: true } | { protected: true; allowed: false; reason: "sign_in" | "denied" };
|
|
29
|
+
|
|
30
|
+
interface MediaRow {
|
|
31
|
+
id: string;
|
|
32
|
+
folder: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The media item behind a storage key, with its folder's name. */
|
|
36
|
+
async function mediaByKey(db: unknown, key: string): Promise<MediaRow | null> {
|
|
37
|
+
// Kysely, typed loosely: EmDash's schema types aren't exported.
|
|
38
|
+
const k = db as {
|
|
39
|
+
selectFrom(t: string): {
|
|
40
|
+
leftJoin(t: string, a: string, b: string): { select(c: string[]): { where(c: string, op: string, v: string): { executeTakeFirst(): Promise<Record<string, unknown> | undefined> } } };
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const row = await k
|
|
44
|
+
.selectFrom("media")
|
|
45
|
+
.leftJoin("media_folders", "media_folders.id", "media.folder_id")
|
|
46
|
+
.select(["media.id as id", "media_folders.name as folder"])
|
|
47
|
+
.where("media.storage_key", "=", key)
|
|
48
|
+
.executeTakeFirst();
|
|
49
|
+
return row ? { id: String(row.id), folder: (row.folder as string | null) ?? null } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Published entries that use the media item: collection and id. */
|
|
53
|
+
async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ collection: string; id: string }>> {
|
|
54
|
+
const k = db as {
|
|
55
|
+
selectFrom(t: string): {
|
|
56
|
+
innerJoin(t: string, cb: (j: { onRef(a: string, op: string, b: string): { onRef(a: string, op: string, b: string): unknown } }) => unknown): {
|
|
57
|
+
select(c: string[]): {
|
|
58
|
+
where(c: string, op: string, v: unknown): {
|
|
59
|
+
where(c: string, op: string, v: unknown): { where(c: string, op: string, v: unknown): { limit(n: number): { execute(): Promise<Array<Record<string, unknown>>> } } };
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const rows = await k
|
|
66
|
+
.selectFrom("_emdash_media_usage_sources as s")
|
|
67
|
+
.innerJoin("_emdash_media_usage as u", (j) => j.onRef("u.source_key", "=", "s.source_key").onRef("u.generation", "=", "s.current_generation"))
|
|
68
|
+
.select(["s.collection_slug as collection", "s.content_id as id"])
|
|
69
|
+
.where("u.media_id", "=", mediaId)
|
|
70
|
+
.where("s.content_status", "=", "published")
|
|
71
|
+
.where("s.content_deleted_at", "is", null)
|
|
72
|
+
.limit(50)
|
|
73
|
+
.execute();
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
return rows
|
|
76
|
+
.filter((r) => typeof r.collection === "string" && typeof r.id === "string")
|
|
77
|
+
.map((r) => ({ collection: r.collection as string, id: r.id as string }))
|
|
78
|
+
.filter((e) => !seen.has(`${e.collection}:${e.id}`) && Boolean(seen.add(`${e.collection}:${e.id}`)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
|
|
82
|
+
export function bodyClauses(body: unknown): string[][] {
|
|
83
|
+
const blocks = Array.isArray(body) ? body : [];
|
|
84
|
+
const out: string[][] = [];
|
|
85
|
+
const split = splitAtPaywall(blocks);
|
|
86
|
+
if (split.paywall && split.levels.length) out.push(split.levels);
|
|
87
|
+
for (const s of sections(blocks)) if (s.members && s.levels.length) out.push(s.levels);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** May this reader read the whole entry: its rule, drip, and every part with levels picked? */
|
|
92
|
+
async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
|
|
93
|
+
const { getEmDashEntry } = await import("emdash");
|
|
94
|
+
const result = (await getEmDashEntry(entry.collection, entry.id)) as { entry?: { data?: Record<string, unknown> } | null } | null;
|
|
95
|
+
const data = result?.entry?.data;
|
|
96
|
+
if (!data) return false;
|
|
97
|
+
const terms = data.terms as Terms | undefined;
|
|
98
|
+
const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => [PAYWALL_BLOCK, "redeemid-members", ITEM_BLOCK].includes((b as { _type?: string })?._type ?? "")));
|
|
99
|
+
const item = soldItem(body);
|
|
100
|
+
if (item) {
|
|
101
|
+
const m = (await viewer(astro)).member;
|
|
102
|
+
if (m && m.status !== "disabled" && holdsItem(item, m.tags)) return true;
|
|
103
|
+
}
|
|
104
|
+
const verdict = await readEntry(astro, { collection: entry.collection, terms });
|
|
105
|
+
if (!verdict.allowed) return false;
|
|
106
|
+
const clauses = bodyClauses(body);
|
|
107
|
+
return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function ruleFor(config: RedeemIDConfig | null, folder: string | null): FileRule | null {
|
|
111
|
+
if (!folder || !config?.files?.length) return null;
|
|
112
|
+
const name = folder.trim().toLowerCase();
|
|
113
|
+
return config.files.find((r) => r.folder.trim().toLowerCase() === name) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Decide one download. `key` is the storage key from the file URL. */
|
|
117
|
+
export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
|
|
118
|
+
if (!config?.files?.length) return { protected: false };
|
|
119
|
+
const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
|
|
120
|
+
const db = locals.emdash?.db;
|
|
121
|
+
if (!db) return { protected: false };
|
|
122
|
+
const media = await mediaByKey(db, key);
|
|
123
|
+
const rule = ruleFor(config, media?.folder ?? null);
|
|
124
|
+
if (!media || !rule) return { protected: false };
|
|
125
|
+
|
|
126
|
+
const role = locals.user?.role ?? 0;
|
|
127
|
+
if (role > SUBSCRIBER) return { protected: true, allowed: true };
|
|
128
|
+
const v = await viewer(astro);
|
|
129
|
+
if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
|
|
130
|
+
const m = v.member;
|
|
131
|
+
if (!m || m.status === "disabled") return { protected: true, allowed: false, reason: "denied" };
|
|
132
|
+
|
|
133
|
+
if (rule.levels && (rule.levels.length === 0 ? true : rule.levels.some((l) => v.levels.includes(l) || l === "signed-in"))) {
|
|
134
|
+
return { protected: true, allowed: true };
|
|
135
|
+
}
|
|
136
|
+
if (rule.features?.length && featuresFor(m, config.features, config.levels).some((f) => rule.features?.includes(f))) {
|
|
137
|
+
return { protected: true, allowed: true };
|
|
138
|
+
}
|
|
139
|
+
if (rule.entries) {
|
|
140
|
+
for (const e of await entriesUsing(db, media.id)) {
|
|
141
|
+
if (await readsWhole(astro, e)) return { protected: true, allowed: true };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { protected: true, allowed: false, reason: "denied" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every storage key a file URL path could name: the key after each layer of
|
|
149
|
+
* percent-decoding, because storage backends differ in whether they decode
|
|
150
|
+
* keys. The guard checks all of them, so an encoded key can't slip past.
|
|
151
|
+
*/
|
|
152
|
+
export function keysFromPath(pathname: string): string[] | null {
|
|
153
|
+
if (!pathname.startsWith(FILE_PATH)) return null;
|
|
154
|
+
let candidate = pathname.slice(FILE_PATH.length);
|
|
155
|
+
if (!candidate) return null;
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
for (let layer = 0; layer < 4; layer++) {
|
|
158
|
+
let decoded: string;
|
|
159
|
+
try {
|
|
160
|
+
decoded = decodeURIComponent(candidate);
|
|
161
|
+
} catch {
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
out.push(decoded);
|
|
165
|
+
if (decoded === candidate) break;
|
|
166
|
+
candidate = decoded;
|
|
167
|
+
}
|
|
168
|
+
return [...new Set(out)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Decide a download that may name several keys: the first protected one decides. */
|
|
172
|
+
export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
|
|
173
|
+
for (const key of keys) {
|
|
174
|
+
const v = await fileAccess(astro, config, key);
|
|
175
|
+
if (v.protected) return v;
|
|
176
|
+
}
|
|
177
|
+
return { protected: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function escapeHtml(s: string): string {
|
|
181
|
+
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The page a refused download gets. */
|
|
185
|
+
export function refusal(reason: "sign_in" | "denied", signIn: string, config: RedeemIDConfig | null = null): Response {
|
|
186
|
+
const status = reason === "sign_in" ? 401 : 403;
|
|
187
|
+
const t = (s: string) => escapeHtml(text(config, s));
|
|
188
|
+
const body =
|
|
189
|
+
reason === "sign_in"
|
|
190
|
+
? `<p>${t("Sign in to download this file.")}</p><p><a href="${escapeHtml(signIn)}">${t("Sign in")}</a></p>`
|
|
191
|
+
: `<p>${t("Your membership doesn't include this file.")}</p>`;
|
|
192
|
+
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>${t("File not available")}</title></head><body><main>${body}</main></body></html>`, {
|
|
193
|
+
status,
|
|
194
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "private, no-store", "x-robots-tag": "noindex" },
|
|
195
|
+
});
|
|
196
|
+
}
|
package/src/guard.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { defineMiddleware } from "astro:middleware";
|
|
12
12
|
|
|
13
|
+
import { signInUrl } from "./access.js";
|
|
14
|
+
import { keysFromPath, pathAccess, refusal } from "./files.js";
|
|
13
15
|
import { siteConfig } from "./site-config.js";
|
|
14
16
|
|
|
15
17
|
const SUBSCRIBER = 10;
|
|
@@ -28,11 +30,50 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
|
|
|
28
30
|
return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* EmDash skips the session on its public file route, so resolve the signed-in
|
|
35
|
+
* user here: the access check needs it.
|
|
36
|
+
*/
|
|
37
|
+
async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
|
|
38
|
+
const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
|
|
39
|
+
if (locals.user || !locals.emdash?.db) return context;
|
|
40
|
+
const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
|
|
41
|
+
if (!sessionUser?.id) return context;
|
|
42
|
+
const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
|
|
43
|
+
const user = await createKyselyAdapter(locals.emdash.db as never).getUserById(sessionUser.id);
|
|
44
|
+
if (!user || user.disabled) return context;
|
|
45
|
+
return { ...context, locals: { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
49
|
+
const { locals, url } = context;
|
|
32
50
|
const user = (locals as { user?: { role?: number } | null }).user;
|
|
51
|
+
const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
52
|
+
|
|
53
|
+
// Protected files: checked for everyone, on every download.
|
|
54
|
+
const keys = config?.files?.length ? keysFromPath(url.pathname) : null;
|
|
55
|
+
if (keys) {
|
|
56
|
+
let verdict;
|
|
57
|
+
try {
|
|
58
|
+
verdict = await pathAccess(await withUser(context), config, keys);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Fail closed: a file we can't check isn't served.
|
|
61
|
+
console.error("[redeemid] file check failed:", (e as Error).message);
|
|
62
|
+
return refusal("denied", signInUrl(url.pathname), config);
|
|
63
|
+
}
|
|
64
|
+
if (verdict.protected) {
|
|
65
|
+
if (!verdict.allowed) return refusal(verdict.reason, signInUrl(url.pathname), config);
|
|
66
|
+
const res = await next();
|
|
67
|
+
const out = new Response(res.body, res);
|
|
68
|
+
out.headers.set("cache-control", "private, no-store");
|
|
69
|
+
out.headers.set("vary", "Cookie");
|
|
70
|
+
out.headers.set("x-robots-tag", "noindex");
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
if (!user || user.role !== SUBSCRIBER) return next();
|
|
34
76
|
|
|
35
|
-
const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
36
77
|
if (memberMayUse(url.pathname, config?.memberPaths)) return next();
|
|
37
78
|
|
|
38
79
|
if (url.pathname.startsWith("/_emdash/admin")) {
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
|
|
|
58
58
|
{ pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
|
|
59
59
|
{ pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
|
|
60
60
|
{ pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
|
|
61
|
+
{ pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
|
|
62
|
+
{ pattern: `${BASE}/privacy`, entrypoint: "@redeemid/emdash/routes/privacy.ts" },
|
|
61
63
|
],
|
|
62
64
|
publicRoutes: [`${BASE}/`],
|
|
63
65
|
storage: STORAGE_CONFIG,
|