stack-site-builder 1.13.0 โ 1.14.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/CHANGELOG.md +19 -0
- package/README.md +51 -0
- package/index.mjs +9 -0
- package/package.json +1 -1
- package/src/components/ArticleCard.astro +9 -3
- package/src/components/ArticleLink.astro +7 -2
- package/src/components/BlogIndex.astro +2 -0
- package/src/components/CategoryIndex.astro +2 -0
- package/src/components/CodeSamples.astro +6 -1
- package/src/components/ConceptCard.astro +9 -3
- package/src/components/ConceptIndex.astro +2 -0
- package/src/components/ConceptLink.astro +7 -2
- package/src/components/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/Home.astro +2 -0
- package/src/components/MermaidLoader.astro +3 -1
- package/src/components/PricingSection.astro +4 -1
- package/src/components/PrivateGate.astro +135 -0
- package/src/components/ProjectViewer.astro +24 -13
- package/src/components/SlidesIndex.astro +8 -2
- package/src/components/StackCard.astro +15 -3
- package/src/components/StackDetail.astro +9 -0
- package/src/components/TagIndex.astro +2 -0
- package/src/components/TocRail.astro +12 -6
- package/src/components/VendorIndex.astro +2 -0
- package/src/content.ts +21 -0
- package/src/i18n/ui.ts +14 -0
- package/src/layouts/BaseLayout.astro +10 -4
- package/src/lib/private-client.ts +160 -0
- package/src/lib/private.ts +129 -0
- package/src/lib/reinit.ts +12 -0
- package/src/pages/[...lang]/[page].astro +7 -2
- package/src/pages/[...lang]/article/[...id].astro +7 -2
- package/src/pages/[...lang]/concept/[...id].astro +7 -2
- package/src/pages/[...lang]/stack/[...id].astro +7 -2
|
@@ -26,9 +26,12 @@ interface Props {
|
|
|
26
26
|
repo?: string;
|
|
27
27
|
version?: string;
|
|
28
28
|
deprecated?: boolean;
|
|
29
|
+
/** Private entry: show a lock and the (public) `teaser` instead of the description. */
|
|
30
|
+
isPrivate?: boolean;
|
|
31
|
+
teaser?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated } =
|
|
34
|
+
const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated, isPrivate = false, teaser } =
|
|
32
35
|
Astro.props;
|
|
33
36
|
const t = useTranslations(lang);
|
|
34
37
|
const href = getRelativeLocaleUrl(lang, `stack/${slug}/`);
|
|
@@ -54,7 +57,11 @@ const langTokens = (language ?? '')
|
|
|
54
57
|
.split('/')
|
|
55
58
|
.map((s) => s.trim())
|
|
56
59
|
.filter(Boolean);
|
|
57
|
-
|
|
60
|
+
// Private entries: the card carries only public copy โ the title and the
|
|
61
|
+
// authored `teaser` (description stays inside the encrypted detail page), and
|
|
62
|
+
// the search index must not include the hidden description either.
|
|
63
|
+
const shownDesc = isPrivate ? (teaser ?? '') : description;
|
|
64
|
+
const searchText = `${name} ${shownDesc} ${tags.join(' ')}`.toLowerCase();
|
|
58
65
|
---
|
|
59
66
|
|
|
60
67
|
{/* The card is a container, not a link: the name is the stretched primary link
|
|
@@ -85,6 +92,11 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
|
|
|
85
92
|
<div class="min-w-0">
|
|
86
93
|
<h3 class="truncate text-base font-semibold text-[var(--aas-text)]">
|
|
87
94
|
<a href={href} class="no-underline after:absolute after:inset-0 after:content-['']">
|
|
95
|
+
{isPrivate && (
|
|
96
|
+
<span aria-label={t('private.badge')} title={t('private.badge')}>
|
|
97
|
+
๐{' '}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
88
100
|
{name}
|
|
89
101
|
</a>
|
|
90
102
|
</h3>
|
|
@@ -100,7 +112,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
|
|
|
100
112
|
<p
|
|
101
113
|
class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
|
|
102
114
|
data-card-desc
|
|
103
|
-
set:html={inlineMd(
|
|
115
|
+
set:html={inlineMd(shownDesc)}
|
|
104
116
|
/>
|
|
105
117
|
|
|
106
118
|
{/* Secondary facts: version ยท release date ยท language ยท license.
|
|
@@ -402,8 +402,16 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
|
|
|
402
402
|
|
|
403
403
|
<script>
|
|
404
404
|
import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
|
|
405
|
+
import { initOnReady } from '../lib/reinit';
|
|
405
406
|
|
|
407
|
+
// Wrapped in initOnReady: on a private tool page all of this DOM sits inside
|
|
408
|
+
// the encrypted body, so the load-time run wires nothing (null rail, empty
|
|
409
|
+
// lists โ its document listeners are inert) and the post-decryption run does
|
|
410
|
+
// the real wiring against the injected DOM.
|
|
411
|
+
initOnReady(() => {
|
|
406
412
|
const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
|
|
413
|
+
if (rail?.dataset.aasInit) return;
|
|
414
|
+
if (rail) rail.dataset.aasInit = '1';
|
|
407
415
|
// Scroll-spy + back-to-top + prose-<details> persistence, shared with
|
|
408
416
|
// TocRail.astro. The tab/version/example handlers below re-run the spy after
|
|
409
417
|
// changing which headings are visible.
|
|
@@ -481,6 +489,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
|
|
|
481
489
|
});
|
|
482
490
|
}),
|
|
483
491
|
);
|
|
492
|
+
});
|
|
484
493
|
</script>
|
|
485
494
|
|
|
486
495
|
{
|
|
@@ -64,11 +64,17 @@ const hasContent = items.length > 0 || relatedTools.length > 0 || Boolean(projec
|
|
|
64
64
|
|
|
65
65
|
<script>
|
|
66
66
|
import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
|
|
67
|
+
import { initOnReady } from '../lib/reinit';
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
// Re-runs after private-content decryption (the rail ships inside the
|
|
70
|
+
// encrypted body); the dataset flag keeps a live rail from double-wiring.
|
|
71
|
+
initOnReady(() => {
|
|
72
|
+
const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
|
|
73
|
+
if (rail && !rail.dataset.aasInit) {
|
|
74
|
+
rail.dataset.aasInit = '1';
|
|
75
|
+
startScrollSpy(rail);
|
|
76
|
+
initBackToTop(rail);
|
|
77
|
+
initReveals(rail);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
74
80
|
</script>
|
package/src/content.ts
CHANGED
|
@@ -107,6 +107,11 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
107
107
|
)
|
|
108
108
|
.default([]),
|
|
109
109
|
featured: z.boolean().default(false),
|
|
110
|
+
// Login-gated entry: the body ships encrypted and listings show only the
|
|
111
|
+
// title (+ `teaser`, an explicitly PUBLIC one-liner written for the gate).
|
|
112
|
+
// See docs/private-content-design.md. Requires the AAS_PRIVATE_* env vars.
|
|
113
|
+
private: z.boolean().default(false),
|
|
114
|
+
teaser: z.string().optional(),
|
|
110
115
|
}),
|
|
111
116
|
});
|
|
112
117
|
|
|
@@ -147,6 +152,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
147
152
|
.optional(),
|
|
148
153
|
tags: z.array(z.string()).default([]),
|
|
149
154
|
draft: z.boolean().default(false),
|
|
155
|
+
// Login-gated entry (encrypted body; listings show title + optional
|
|
156
|
+
// PUBLIC `teaser`). See docs/private-content-design.md.
|
|
157
|
+
private: z.boolean().default(false),
|
|
158
|
+
teaser: z.string().optional(),
|
|
150
159
|
}),
|
|
151
160
|
});
|
|
152
161
|
|
|
@@ -181,6 +190,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
181
190
|
tags: z.array(z.string()).default([]),
|
|
182
191
|
order: z.number().optional(), // manual sort on the index (lower first)
|
|
183
192
|
draft: z.boolean().default(false),
|
|
193
|
+
// Login-gated entry (encrypted body; listings show title + optional
|
|
194
|
+
// PUBLIC `teaser`). See docs/private-content-design.md.
|
|
195
|
+
private: z.boolean().default(false),
|
|
196
|
+
teaser: z.string().optional(),
|
|
184
197
|
}),
|
|
185
198
|
});
|
|
186
199
|
|
|
@@ -221,6 +234,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
221
234
|
toc_level: z.number().int().min(2).max(4).default(2),
|
|
222
235
|
toc_open: z.boolean().default(true),
|
|
223
236
|
draft: z.boolean().default(false),
|
|
237
|
+
// Login-gated deck (encrypted body; the slides index shows title +
|
|
238
|
+
// optional PUBLIC `teaser`). See docs/private-content-design.md.
|
|
239
|
+
private: z.boolean().default(false),
|
|
240
|
+
teaser: z.string().optional(),
|
|
224
241
|
}),
|
|
225
242
|
});
|
|
226
243
|
|
|
@@ -249,6 +266,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
249
266
|
navLabel: z.string().optional(),
|
|
250
267
|
order: z.number().default(0),
|
|
251
268
|
draft: z.boolean().default(false),
|
|
269
|
+
// Login-gated page (encrypted body; the nav/meta show title + optional
|
|
270
|
+
// PUBLIC `teaser`). See docs/private-content-design.md.
|
|
271
|
+
private: z.boolean().default(false),
|
|
272
|
+
teaser: z.string().optional(),
|
|
252
273
|
}),
|
|
253
274
|
});
|
|
254
275
|
|
package/src/i18n/ui.ts
CHANGED
|
@@ -67,6 +67,13 @@ export const ui = {
|
|
|
67
67
|
'nav.menu': 'Menu',
|
|
68
68
|
'code.copy': 'Copy code',
|
|
69
69
|
'code.copied': 'Copied',
|
|
70
|
+
'private.badge': 'Private',
|
|
71
|
+
'private.locked': 'This content is private. Log in to view it.',
|
|
72
|
+
'private.id': 'ID',
|
|
73
|
+
'private.password': 'Password',
|
|
74
|
+
'private.submit': 'Unlock',
|
|
75
|
+
'private.error': 'Wrong ID or password.',
|
|
76
|
+
'private.logout': 'Log out',
|
|
70
77
|
'slides.title': 'Slides',
|
|
71
78
|
'slides.tagline': 'Concept decks โ the same ideas as the concept pages, in slide form.',
|
|
72
79
|
'slides.deck': 'Deck',
|
|
@@ -209,6 +216,13 @@ export const ui = {
|
|
|
209
216
|
'nav.menu': '๋ฉ๋ด',
|
|
210
217
|
'code.copy': '์ฝ๋ ๋ณต์ฌ',
|
|
211
218
|
'code.copied': '๋ณต์ฌ๋จ',
|
|
219
|
+
'private.badge': '๋น๊ณต๊ฐ',
|
|
220
|
+
'private.locked': '๋น๊ณต๊ฐ ์ฝํ
์ธ ์
๋๋ค. ๋ก๊ทธ์ธ ํ ๋ณผ ์ ์์ต๋๋ค.',
|
|
221
|
+
'private.id': '์์ด๋',
|
|
222
|
+
'private.password': '๋น๋ฐ๋ฒํธ',
|
|
223
|
+
'private.submit': '์ ๊ธ ํด์ ',
|
|
224
|
+
'private.error': '์์ด๋ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.',
|
|
225
|
+
'private.logout': '๋ก๊ทธ์์',
|
|
212
226
|
'slides.title': '์ฌ๋ผ์ด๋',
|
|
213
227
|
'slides.tagline': '๊ฐ๋
๋ฑ โ ๊ฐ๋
ํ์ด์ง์ ๊ฐ์ ๋ด์ฉ์ ์ฌ๋ผ์ด๋๋ก ์ฎ๊ฒผ์ต๋๋ค.',
|
|
214
228
|
'slides.deck': '๋ฑ',
|
|
@@ -17,9 +17,11 @@ interface Props {
|
|
|
17
17
|
path: string;
|
|
18
18
|
/** Wider container โ used by detail pages that have a right-rail TOC. */
|
|
19
19
|
wide?: boolean;
|
|
20
|
+
/** Private entries: ask crawlers not to index the gate page. */
|
|
21
|
+
noindex?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
const { title, lang, path, wide = false } = Astro.props;
|
|
24
|
+
const { title, lang, path, wide = false, noindex = false } = Astro.props;
|
|
23
25
|
const t = useTranslations(lang);
|
|
24
26
|
// Collapse any authored line breaks (used for on-page wrapping) to a single
|
|
25
27
|
// space so the SEO meta description stays one clean line.
|
|
@@ -113,6 +115,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
|
|
|
113
115
|
<meta name="generator" content={Astro.generator} />
|
|
114
116
|
<title>{title}</title>
|
|
115
117
|
<meta name="description" content={description} />
|
|
118
|
+
{noindex && <meta name="robots" content="noindex" />}
|
|
116
119
|
<link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
|
|
117
120
|
{/* On the root (default-locale) home, send first-time visitors to the
|
|
118
121
|
locale matching their browser โ unless they've already picked one (the
|
|
@@ -262,6 +265,8 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
|
|
|
262
265
|
<BackToTop lang={lang} />
|
|
263
266
|
|
|
264
267
|
<script>
|
|
268
|
+
import { initOnReady } from '../lib/reinit';
|
|
269
|
+
|
|
265
270
|
// Clicking a heading's "#" anchor copies that section's URL to the
|
|
266
271
|
// clipboard (and updates the address bar) instead of just navigating.
|
|
267
272
|
document.addEventListener('click', (e) => {
|
|
@@ -279,8 +284,9 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
|
|
|
279
284
|
// Mount a copy-to-clipboard button on every code block: README/overview
|
|
280
285
|
// prose (.prose pre), the file viewer (.aas-projfile), and the Code tab
|
|
281
286
|
// (.aas-code). The button is pinned to a non-scrolling host so it stays in
|
|
282
|
-
// place on long, horizontally-scrolling code.
|
|
283
|
-
|
|
287
|
+
// place on long, horizontally-scrolling code. Re-runs after private-content
|
|
288
|
+
// decryption (mount() skips hosts that already have a button).
|
|
289
|
+
initOnReady(function mountCopyButtons() {
|
|
284
290
|
const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
|
|
285
291
|
const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
|
|
286
292
|
const COPY_ICON =
|
|
@@ -334,7 +340,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
|
|
|
334
340
|
const fig = el.closest('figure');
|
|
335
341
|
mount(el, (fig as HTMLElement) ?? wrap(el));
|
|
336
342
|
});
|
|
337
|
-
})
|
|
343
|
+
});
|
|
338
344
|
|
|
339
345
|
// Close any open <details> dropdown when clicking outside it (or on Esc).
|
|
340
346
|
// Only nav dropdowns โ leave file-tree folders (.aas-tree-dir) and prose
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of private content: login (PBKDF2 โ unwrap the site key),
|
|
3
|
+
* session caching in localStorage, AES-GCM decryption and DOM injection.
|
|
4
|
+
* Crypto parameters must match src/lib/private.ts. Loaded only by PrivateGate.
|
|
5
|
+
*/
|
|
6
|
+
import { PRIVATE_DECRYPTED_EVENT } from './reinit';
|
|
7
|
+
|
|
8
|
+
const KDF_ITERATIONS = 600_000;
|
|
9
|
+
const STORE = 'aas:pk';
|
|
10
|
+
|
|
11
|
+
interface GateData {
|
|
12
|
+
iv: string;
|
|
13
|
+
ct: string;
|
|
14
|
+
users: { h: string; s: string; iv: string; w: string }[];
|
|
15
|
+
salt: string; // build salt for id hashing, base64
|
|
16
|
+
days: number; // session lifetime (0 = never expires)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const dec = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
20
|
+
const enc = (b: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(b)));
|
|
21
|
+
|
|
22
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
23
|
+
const d = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
|
|
24
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
28
|
+
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [
|
|
29
|
+
'deriveKey',
|
|
30
|
+
]);
|
|
31
|
+
return crypto.subtle.deriveKey(
|
|
32
|
+
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations: KDF_ITERATIONS },
|
|
33
|
+
base,
|
|
34
|
+
{ name: 'AES-GCM', length: 256 },
|
|
35
|
+
false,
|
|
36
|
+
['decrypt'],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function aesDecrypt(keyBytes: Uint8Array | CryptoKey, iv: Uint8Array, data: Uint8Array): Promise<ArrayBuffer> {
|
|
41
|
+
const key =
|
|
42
|
+
keyBytes instanceof CryptoKey
|
|
43
|
+
? keyBytes
|
|
44
|
+
: await crypto.subtle.importKey('raw', keyBytes as BufferSource, 'AES-GCM', false, ['decrypt']);
|
|
45
|
+
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, data as BufferSource);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function storedKey(days: number): Uint8Array | null {
|
|
49
|
+
try {
|
|
50
|
+
const raw = localStorage.getItem(STORE);
|
|
51
|
+
if (!raw) return null;
|
|
52
|
+
const { k, t } = JSON.parse(raw) as { k: string; t: number };
|
|
53
|
+
if (days > 0 && Date.now() - t > days * 86_400_000) {
|
|
54
|
+
localStorage.removeItem(STORE);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return dec(k);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clearSession(): void {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.removeItem(STORE);
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Swap the gate for the decrypted HTML; re-run inline scripts; notify re-init hooks. */
|
|
72
|
+
function inject(gate: HTMLElement, html: string, logoutLabel: string): void {
|
|
73
|
+
const host = document.createElement('div');
|
|
74
|
+
host.innerHTML = html;
|
|
75
|
+
// innerHTML-injected <script> tags don't execute โ recreate each one so
|
|
76
|
+
// classic/inline scripts run (hoisted module scripts re-init via the event).
|
|
77
|
+
host.querySelectorAll('script').forEach((old) => {
|
|
78
|
+
const s = document.createElement('script');
|
|
79
|
+
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
|
80
|
+
s.textContent = old.textContent;
|
|
81
|
+
old.replaceWith(s);
|
|
82
|
+
});
|
|
83
|
+
// A discreet logout control above the content.
|
|
84
|
+
const bar = document.createElement('div');
|
|
85
|
+
bar.className = 'aas-private-bar';
|
|
86
|
+
const btn = document.createElement('button');
|
|
87
|
+
btn.type = 'button';
|
|
88
|
+
btn.className = 'aas-private-logout';
|
|
89
|
+
btn.textContent = `\u{1F513} ${logoutLabel}`;
|
|
90
|
+
btn.addEventListener('click', () => {
|
|
91
|
+
clearSession();
|
|
92
|
+
location.reload();
|
|
93
|
+
});
|
|
94
|
+
bar.appendChild(btn);
|
|
95
|
+
gate.replaceWith(bar, ...host.childNodes);
|
|
96
|
+
document.dispatchEvent(new CustomEvent(PRIVATE_DECRYPTED_EVENT));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function tryDecrypt(gate: HTMLElement, data: GateData, keyBytes: Uint8Array, logoutLabel: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
const html = new TextDecoder().decode(await aesDecrypt(keyBytes, dec(data.iv), dec(data.ct)));
|
|
102
|
+
inject(gate, html, logoutLabel);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Wire one gate element (PrivateGate renders exactly one per page). */
|
|
110
|
+
export async function mountGate(gate: HTMLElement): Promise<void> {
|
|
111
|
+
const data = JSON.parse(gate.querySelector('[data-private-data]')!.textContent!) as GateData;
|
|
112
|
+
const logoutLabel = gate.dataset.logoutLabel ?? 'Log out';
|
|
113
|
+
|
|
114
|
+
// Already logged in on this device โ decrypt with no form flash. A failure
|
|
115
|
+
// means the master secret rotated: drop the stale key and show the form.
|
|
116
|
+
const cachedK = storedKey(data.days);
|
|
117
|
+
if (cachedK && (await tryDecrypt(gate, data, cachedK, logoutLabel))) return;
|
|
118
|
+
if (cachedK) clearSession();
|
|
119
|
+
gate.querySelector<HTMLElement>('[data-private-form]')!.hidden = false;
|
|
120
|
+
|
|
121
|
+
const form = gate.querySelector('form')!;
|
|
122
|
+
const error = gate.querySelector<HTMLElement>('[data-private-error]')!;
|
|
123
|
+
form.addEventListener('submit', async (e) => {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
error.hidden = true;
|
|
126
|
+
const idInput = form.querySelector<HTMLInputElement>('input[name="id"]')!;
|
|
127
|
+
const pwInput = form.querySelector<HTMLInputElement>('input[name="password"]')!;
|
|
128
|
+
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
|
|
129
|
+
button.disabled = true;
|
|
130
|
+
try {
|
|
131
|
+
const id = idInput.value.trim().toLowerCase();
|
|
132
|
+
const salt = dec(data.salt);
|
|
133
|
+
const idBytes = new TextEncoder().encode(id);
|
|
134
|
+
const joined = new Uint8Array(salt.length + idBytes.length);
|
|
135
|
+
joined.set(salt);
|
|
136
|
+
joined.set(idBytes, salt.length);
|
|
137
|
+
const h = await sha256Hex(joined);
|
|
138
|
+
const user = data.users.find((u) => u.h === h);
|
|
139
|
+
if (user) {
|
|
140
|
+
const kek = await deriveKek(pwInput.value, dec(user.s));
|
|
141
|
+
try {
|
|
142
|
+
const k = new Uint8Array(await aesDecrypt(kek, dec(user.iv), dec(user.w)));
|
|
143
|
+
if (await tryDecrypt(gate, data, k, logoutLabel)) {
|
|
144
|
+
try {
|
|
145
|
+
localStorage.setItem(STORE, JSON.stringify({ k: enc(k), t: Date.now() }));
|
|
146
|
+
} catch {
|
|
147
|
+
/* private browsing โ session just won't persist */
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* wrong password (unwrap failed) โ fall through to the error */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
error.hidden = false;
|
|
156
|
+
} finally {
|
|
157
|
+
button.disabled = false;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-side half of private (login-gated) content. Runs only during
|
|
3
|
+
* `astro build` / `astro dev` (Node): derives the site content key, wraps it
|
|
4
|
+
* per user, and encrypts rendered HTML. The browser half (login + decrypt)
|
|
5
|
+
* lives in ./private-client.ts; the whole design is documented in
|
|
6
|
+
* docs/private-content-design.md.
|
|
7
|
+
*
|
|
8
|
+
* Env contract (see .env.sample in a consuming site):
|
|
9
|
+
* AAS_PRIVATE_USERS "id:password,id2:password2" (plaintext passwords)
|
|
10
|
+
* AAS_PRIVATE_MASTER_SECRET stable secret K derives from; rotate = global logout
|
|
11
|
+
* AAS_PRIVATE_SESSION_DAYS client session lifetime in days (0 = never; default 30)
|
|
12
|
+
*/
|
|
13
|
+
import { createCipheriv, createHash, hkdfSync, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
/** Must match private-client.ts. */
|
|
16
|
+
export const KDF_ITERATIONS = 600_000;
|
|
17
|
+
const HKDF_INFO = 'aas-private-v1';
|
|
18
|
+
const MIN_PASSWORD_LENGTH = 10;
|
|
19
|
+
|
|
20
|
+
export interface PrivateUserRecord {
|
|
21
|
+
/** SHA-256(buildSalt + lowercase(id)), hex โ the deployed site never carries raw ids. */
|
|
22
|
+
h: string;
|
|
23
|
+
/** PBKDF2 salt, base64. */
|
|
24
|
+
s: string;
|
|
25
|
+
/** AES-GCM IV for the wrapped key, base64. */
|
|
26
|
+
iv: string;
|
|
27
|
+
/** wrappedK = AES-256-GCM(KEK, K) with auth tag appended, base64. */
|
|
28
|
+
w: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const b64 = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64');
|
|
32
|
+
|
|
33
|
+
interface PrivateConfig {
|
|
34
|
+
key: Buffer; // K โ the site content key
|
|
35
|
+
users: PrivateUserRecord[];
|
|
36
|
+
buildSalt: string; // base64; used by the client to hash the entered id
|
|
37
|
+
sessionDays: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let cached: PrivateConfig | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse env + derive keys, once per build. Throws (failing the build loudly)
|
|
44
|
+
* when private entries exist but the env contract isn't met โ a misconfigured
|
|
45
|
+
* build must not ship silently locked-forever pages.
|
|
46
|
+
*/
|
|
47
|
+
function getConfig(): PrivateConfig {
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
const secret = process.env.AAS_PRIVATE_MASTER_SECRET?.trim();
|
|
51
|
+
const usersRaw = process.env.AAS_PRIVATE_USERS?.trim();
|
|
52
|
+
if (!secret || !usersRaw) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'[private] this site has `private: true` entries, but AAS_PRIVATE_MASTER_SECRET ' +
|
|
55
|
+
'and/or AAS_PRIVATE_USERS is not set. Set both in .env (locally) or as CI ' +
|
|
56
|
+
'secrets โ see docs/private-content-design.md.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// K derives from the master secret (not random): ordinary redeploys keep
|
|
61
|
+
// logged-in devices working; rotating the secret is the global-logout switch.
|
|
62
|
+
const key = Buffer.from(hkdfSync('sha256', secret, 'aas-private', HKDF_INFO, 32));
|
|
63
|
+
|
|
64
|
+
const buildSaltBytes = randomBytes(16);
|
|
65
|
+
const users: PrivateUserRecord[] = [];
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
for (const pair of usersRaw.split(',')) {
|
|
68
|
+
const idx = pair.indexOf(':');
|
|
69
|
+
if (idx < 1) throw new Error(`[private] AAS_PRIVATE_USERS entry is not "id:password": "${pair.trim()}"`);
|
|
70
|
+
const id = pair.slice(0, idx).trim().toLowerCase();
|
|
71
|
+
const password = pair.slice(idx + 1).trim();
|
|
72
|
+
if (seen.has(id)) throw new Error(`[private] duplicate user id "${id}" in AAS_PRIVATE_USERS`);
|
|
73
|
+
seen.add(id);
|
|
74
|
+
if (password.length < MIN_PASSWORD_LENGTH)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[private] password for user "${id}" is shorter than ${MIN_PASSWORD_LENGTH} chars โ ` +
|
|
77
|
+
'wrapped keys can be brute-forced offline, use a long passphrase',
|
|
78
|
+
);
|
|
79
|
+
const salt = randomBytes(16);
|
|
80
|
+
const kek = pbkdf2Sync(password, salt, KDF_ITERATIONS, 32, 'sha256');
|
|
81
|
+
const iv = randomBytes(12);
|
|
82
|
+
const cipher = createCipheriv('aes-256-gcm', kek, iv);
|
|
83
|
+
const wrapped = Buffer.concat([cipher.update(key), cipher.final(), cipher.getAuthTag()]);
|
|
84
|
+
users.push({
|
|
85
|
+
h: createHash('sha256').update(Buffer.concat([buildSaltBytes, Buffer.from(id)])).digest('hex'),
|
|
86
|
+
s: b64(salt),
|
|
87
|
+
iv: b64(iv),
|
|
88
|
+
w: b64(wrapped),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const daysRaw = process.env.AAS_PRIVATE_SESSION_DAYS?.trim();
|
|
93
|
+
const sessionDays = daysRaw === undefined || daysRaw === '' ? 30 : Number(daysRaw);
|
|
94
|
+
if (!Number.isFinite(sessionDays) || sessionDays < 0)
|
|
95
|
+
throw new Error(`[private] AAS_PRIVATE_SESSION_DAYS must be a number โฅ 0 (got "${daysRaw}")`);
|
|
96
|
+
|
|
97
|
+
console.warn(
|
|
98
|
+
'[private] this build contains private entries โ remember: the SOURCE repo must be ' +
|
|
99
|
+
'private (the .mdx files are plaintext); only the built output is encrypted.',
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
cached = { key, users, buildSalt: b64(buildSaltBytes), sessionDays };
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Everything PrivateGate embeds for the client (records are ~120 bytes/user). */
|
|
107
|
+
export function privateClientData(): { users: PrivateUserRecord[]; salt: string; days: number } {
|
|
108
|
+
const { users, buildSalt, sessionDays } = getConfig();
|
|
109
|
+
return { users, salt: buildSalt, days: sessionDays };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** AES-256-GCM encrypt rendered HTML with the site content key. */
|
|
113
|
+
export function encryptHtml(html: string): { iv: string; ct: string } {
|
|
114
|
+
const { key } = getConfig();
|
|
115
|
+
const iv = randomBytes(12);
|
|
116
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
117
|
+
const ct = Buffer.concat([cipher.update(html, 'utf8'), cipher.final(), cipher.getAuthTag()]);
|
|
118
|
+
return { iv: b64(iv), ct: b64(ct) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pathname registry for the sitemap filter: PrivateGate records every private
|
|
123
|
+
* page it renders; index.mjs (a separate module graph โ hence globalThis) reads
|
|
124
|
+
* it in the sitemap `filter`, which @astrojs/sitemap runs after the pages built.
|
|
125
|
+
*/
|
|
126
|
+
export function registerPrivatePath(pathname: string): void {
|
|
127
|
+
const g = globalThis as { __aasPrivatePaths?: Set<string> };
|
|
128
|
+
(g.__aasPrivatePaths ??= new Set()).add(pathname.replace(/\/?$/, '/'));
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a DOM-wiring routine now AND again whenever private content is decrypted
|
|
3
|
+
* and injected (the injected HTML wasn't there when page scripts first ran).
|
|
4
|
+
* Component scripts whose targets can sit inside a private gate wrap their init
|
|
5
|
+
* in this. `fn` must be idempotent or guard against double-wiring itself.
|
|
6
|
+
*/
|
|
7
|
+
export const PRIVATE_DECRYPTED_EVENT = 'aas:private-decrypted';
|
|
8
|
+
|
|
9
|
+
export function initOnReady(fn: () => void): void {
|
|
10
|
+
fn();
|
|
11
|
+
document.addEventListener(PRIVATE_DECRYPTED_EVENT, () => fn());
|
|
12
|
+
}
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
5
|
import PageDetail from '../../components/PageDetail.astro';
|
|
6
|
+
import PrivateGate from '../../components/PrivateGate.astro';
|
|
6
7
|
import { getPages, pageSlugOf } from '../../lib/pages';
|
|
7
8
|
import { allLocales, langParam } from '../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = pageSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} โ ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<PageDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
5
|
import ArticleDetail from '../../../components/ArticleDetail.astro';
|
|
6
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
6
7
|
import { getArticles, articleSlugOf } from '../../../lib/articles';
|
|
7
8
|
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = articleSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} โ ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`article/${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<ArticleDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|
|
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
5
|
import ConceptDetail from '../../../components/ConceptDetail.astro';
|
|
6
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
6
7
|
import { getConcepts, conceptSlugOf } from '../../../lib/concepts';
|
|
7
8
|
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
9
|
|
|
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
|
|
|
18
19
|
|
|
19
20
|
const { lang, entry } = Astro.props;
|
|
20
21
|
const slug = conceptSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
21
23
|
---
|
|
22
24
|
|
|
23
25
|
<BaseLayout
|
|
24
26
|
title={`${entry.data.title} โ ${site.name}`}
|
|
25
|
-
description={entry.data.description}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
26
28
|
lang={lang}
|
|
27
29
|
path={`concept/${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
28
31
|
>
|
|
29
|
-
<
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<ConceptDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
30
35
|
</BaseLayout>
|