abqari 1.1.2 → 1.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -1
- data/README.md +26 -12
- data/app/views/partials/_head.html.erb +10 -18
- data/lib/abqari/audit/csp.rb +178 -0
- data/lib/abqari/audit.rb +4 -2
- data/lib/abqari/cli/headers.rb +95 -0
- data/lib/abqari/cli.rb +3 -0
- data/lib/abqari/headers_file.rb +28 -11
- data/lib/abqari/hooks.rb +59 -4
- data/lib/abqari/renderer.rb +95 -13
- data/lib/abqari/server.rb +28 -1
- data/lib/abqari/site.rb +9 -0
- data/lib/abqari/speculation_rules.rb +83 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-cloudflare.yml +9 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-github-pages.yml +8 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-render.yml +16 -5
- data/lib/abqari/version.rb +1 -1
- data/lib/abqari.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac44a7f76fcb30cb218104f9e002a0cd958518542912393d7681fceab92abdf0
|
|
4
|
+
data.tar.gz: 8a0cc5fe35425ca6571dbc76549df5105106bf8ff672f67ea9101c26e48af550
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9050a56ac582b3cdb3f4590bf90f6543ee57fa0dd6e1c4b56afc7274a937dfbb805400869d157473019af1a9802da7e88af97e30961f436cbed8a2472f2bb6cb
|
|
7
|
+
data.tar.gz: db4d7cb9c339676e76cc794b8e205fb0000d50266d8b28ae6a53770b7d701a44fd3664d3f3d2076e3fc78cd2f08745ab442a4abd85a5bcbdbb0ef14586a8f26b
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,160 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
_Nothing yet._
|
|
11
11
|
|
|
12
|
+
## [1.3.0] — 2026-10-11
|
|
13
|
+
|
|
14
|
+
If CI runs `bin/audit --strict`, run `bin/audit` locally before
|
|
15
|
+
upgrading: a new Security check can fail an audit that passed on
|
|
16
|
+
1.2.0. Every finding is inline code that is already blocked on any host
|
|
17
|
+
that enforces the site's CSP, so the fix is in the site, not the
|
|
18
|
+
policy.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`bin/audit` now fails on inline code the site's own CSP would
|
|
23
|
+
block.** The generated policy has `style-src 'self'` and pins inline
|
|
24
|
+
scripts by hash, so a template that adds `style="…"`, a `<style>`
|
|
25
|
+
block, an `onclick=`, or an unhashed `<script>` breaks on any host
|
|
26
|
+
that enforces the CSP. It hid because nothing local enforces it: the
|
|
27
|
+
dev server doesn't apply `_headers`, the page renders, and the
|
|
28
|
+
browser only logs a console error. One site had 52 inline
|
|
29
|
+
`style="--ratio: …"` attributes that would have collapsed every photo
|
|
30
|
+
pane the moment its CSP went live.
|
|
31
|
+
|
|
32
|
+
The new **Security** category (`Audit::Csp`) checks every page
|
|
33
|
+
against the CSP in the built `_headers`, so a `headers:` override or
|
|
34
|
+
`csp:` extension is what's checked. It reports one error per page per
|
|
35
|
+
kind of inline code, with an example and how to fix it. It follows
|
|
36
|
+
the spec: hashes, `'unsafe-inline'` (ignored once a hash or nonce is
|
|
37
|
+
present), `'unsafe-hashes'` for attributes, the `-elem`/`-attr`
|
|
38
|
+
directives and the `default-src` fallback. `bin/audit --strict` fails
|
|
39
|
+
on these, so a site that passed yesterday can fail today; every
|
|
40
|
+
finding is code that is already broken wherever the policy is
|
|
41
|
+
applied.
|
|
42
|
+
|
|
43
|
+
## [1.2.0] — 2026-10-11
|
|
44
|
+
|
|
45
|
+
Upgrade if you serve the generated CSP: Chrome has been blocking
|
|
46
|
+
Abqari's own speculation rules, so nothing was prerendered and every
|
|
47
|
+
page logged a CSP violation, plus a Permissions-Policy error about
|
|
48
|
+
`interest-cohort`. Upgrade too if you deploy to Render, which ignores
|
|
49
|
+
`_headers` (`abqari headers` prints the rules it needs), or through the
|
|
50
|
+
shipped GitHub Actions workflows with `fonts:` set, which deployed
|
|
51
|
+
sites without their fonts.
|
|
52
|
+
|
|
53
|
+
On a host that ignores `_headers`, rebuild after upgrading and re-copy
|
|
54
|
+
the CSP and Permissions-Policy: both changed. Workflow files are copied
|
|
55
|
+
into each site at `abqari new`, so the font fix needs adding to existing
|
|
56
|
+
sites by hand.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **`speculation_rules.href_matches`** sets the URL patterns to
|
|
61
|
+
prerender: one pattern or a list. The default is unchanged: the posts
|
|
62
|
+
collection (`/posts/*`, or `/<slug>/*` with a custom `slug:`). A site
|
|
63
|
+
whose articles live in another collection prerendered nothing, which
|
|
64
|
+
is how Sanad's `/news/*` surfaced it. Changing it changes the CSP
|
|
65
|
+
hash (see Fixed).
|
|
66
|
+
|
|
67
|
+
- **`abqari headers`** prints the built `_site/_headers` as Render
|
|
68
|
+
header rules, ready to paste into `render.yaml` or the dashboard's
|
|
69
|
+
Settings → Headers. Render static sites don't read `_headers`, so
|
|
70
|
+
without this the CSP, the security headers and the year-long
|
|
71
|
+
`immutable` cache on `/assets/*` were missing, and Render served
|
|
72
|
+
assets with `max-age=0` (fonts revalidated on every page, so text
|
|
73
|
+
flashed). It reads the built file rather than computing headers, so
|
|
74
|
+
the CSP hashes are the ones the site's bundled engine and config
|
|
75
|
+
produced. Run it with `bundle exec` from the site root.
|
|
76
|
+
`docs/deploying.md` now covers Render's headers, the git-ignored
|
|
77
|
+
`vendor/fonts/` and Render's missing AVIF encoder, and no longer
|
|
78
|
+
tells Render users to `apt-get` packages.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- **Chrome logged a CSP violation on every page, and nothing was
|
|
83
|
+
prerendered.** "Applying inline speculation rules violates the
|
|
84
|
+
following Content Security Policy directive". The generated CSP
|
|
85
|
+
allowed the head's `<script type="speculationrules">` block only
|
|
86
|
+
through `'inline-speculation-rules'`, and Chrome ignores that keyword
|
|
87
|
+
once `script-src` also lists hashes, which it always does (the theme
|
|
88
|
+
bootstrap and the data-saver guard). It hid because nothing visibly
|
|
89
|
+
breaks: the page works, the dev server doesn't apply `_headers`, and
|
|
90
|
+
the unit tests checked the keyword and the hashes separately.
|
|
91
|
+
|
|
92
|
+
The block's body now comes from `Abqari::SpeculationRules`, which the
|
|
93
|
+
head partial emits and `HeadersFile` hashes, so the two can't drift.
|
|
94
|
+
The keyword stays for browsers that honour it. With unchanged config
|
|
95
|
+
the body is byte-identical to 1.1.x, so a hash already pasted into a
|
|
96
|
+
host's settings keeps working. A site that overrides `_head.html.erb`
|
|
97
|
+
keeps matching too, until it sets `href_matches`; then its block
|
|
98
|
+
needs to emit `<%== Abqari::SpeculationRules.script(site) %>`.
|
|
99
|
+
`test/csp_inline_scripts_test.rb` now checks every inline script on a
|
|
100
|
+
built page against the built policy.
|
|
101
|
+
|
|
102
|
+
- **Chrome logged a Permissions-Policy error about `interest-cohort`
|
|
103
|
+
on every page.** The generated `Permissions-Policy` still opted out
|
|
104
|
+
of FLoC, which Google has dropped. It's gone; `camera`, `microphone`
|
|
105
|
+
and `geolocation` stay off.
|
|
106
|
+
|
|
107
|
+
- **Builds on Render logged dozens of false "Slow page" warnings, each
|
|
108
|
+
blaming a "plugin filter".** One build flagged about 45 pages at
|
|
109
|
+
500ms+, while the same 270 pages render in about 5ms each locally.
|
|
110
|
+
There were three causes. The render pool was sized by
|
|
111
|
+
`Etc.nprocessors`, which in a container reports the host's CPUs
|
|
112
|
+
rather than the quota, so dozens of threads queued for one GVL.
|
|
113
|
+
Pages were timed by wall-clock, which counted that queueing and any
|
|
114
|
+
CPU throttling as the page's own time. And the plugin-filter cause
|
|
115
|
+
was attached to every slow page whenever any `:post_process` hook was
|
|
116
|
+
registered, without being timed. It hid because a developer machine
|
|
117
|
+
has the CPUs it reports.
|
|
118
|
+
|
|
119
|
+
Pages are now timed by their thread's CPU time, and the warning says
|
|
120
|
+
"of CPU". The pool follows the cgroup CPU quota when there is one,
|
|
121
|
+
capped at 4 (`Renderer::MAX_RENDER_THREADS`): rendering holds the
|
|
122
|
+
GVL, and a 270-page site built no faster on 8 threads than on 1. A
|
|
123
|
+
plugin filter is named only when it used at least a quarter of the
|
|
124
|
+
page's time, and with that time ("plugin filter 340ms"). Time a page
|
|
125
|
+
spends blocked on I/O no longer counts toward the threshold.
|
|
126
|
+
|
|
127
|
+
- **Sites deployed through the shipped GitHub Actions workflows had no
|
|
128
|
+
custom fonts.** The scaffold git-ignores `vendor/fonts/`, so a CI
|
|
129
|
+
checkout has no font files until `bin/fetch-fonts` runs, and
|
|
130
|
+
`deploy-cloudflare.yml`, `deploy-github-pages.yml` and
|
|
131
|
+
`deploy-render.yml` went straight to `bin/build`. The build passed,
|
|
132
|
+
the head (correctly) omitted the missing font stylesheet, and the
|
|
133
|
+
site shipped in fallback fonts. It hid because the developer's own
|
|
134
|
+
checkout has the fonts, so local builds look right.
|
|
135
|
+
|
|
136
|
+
The workflows now run `bundle exec bin/fetch-fonts` before building;
|
|
137
|
+
it's a no-op for a site with no `fonts:`. **Existing sites keep their
|
|
138
|
+
own copies of the workflows and must add the step themselves**, or
|
|
139
|
+
commit `vendor/fonts/`. Render's own build doesn't use these
|
|
140
|
+
workflows: its build command needs `bin/fetch-fonts` too (see
|
|
141
|
+
`docs/deploying.md`).
|
|
142
|
+
|
|
143
|
+
- **Editing a plugin while `bin/serve` was running broke every page with
|
|
144
|
+
an unexplained `NoMethodError`.** Add a helper in `plugins/`, save a
|
|
145
|
+
layout that calls it, and the next rebuild failed every page with
|
|
146
|
+
`undefined method '…' for an instance of Abqari::RenderContext` until
|
|
147
|
+
the server was restarted. Plugins load once, at `Site.new`, and the
|
|
148
|
+
dev server builds its Site once, so neither a plugin nor anything it
|
|
149
|
+
`require`s from the site's `lib/` is ever reloaded. `plugins/` wasn't
|
|
150
|
+
even watched, so the server had no idea anything had changed. It hid
|
|
151
|
+
because the plugin itself was fine: `bin/build` and a freshly started
|
|
152
|
+
`bin/serve` both load it and work.
|
|
153
|
+
|
|
154
|
+
`bin/serve` now watches `plugins/`. When a plugin, or a `lib/` file a
|
|
155
|
+
plugin required, changes, it logs a one-line warning naming the file
|
|
156
|
+
and saying to restart. A build that fails while that Ruby is stale
|
|
157
|
+
ends its error output with the same reminder, because by then the
|
|
158
|
+
first warning has usually scrolled away. The server deliberately
|
|
159
|
+
doesn't reload or re-exec itself: a half-saved plugin would then take
|
|
160
|
+
it down instead of logging an error. Tracking lives in
|
|
161
|
+
`Site#stale_ruby_sources`.
|
|
162
|
+
|
|
163
|
+
`docs/plugins.md` also said a stale plugin's old *and* new callbacks
|
|
164
|
+
both fire on each rebuild. Only the old ones do.
|
|
165
|
+
|
|
12
166
|
## [1.1.2] — 2026-10-09
|
|
13
167
|
|
|
14
168
|
### Fixed
|
|
@@ -987,7 +1141,9 @@ run a real working author / publisher site.
|
|
|
987
1141
|
commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
|
|
988
1142
|
the `~> 2.4` floor bought.
|
|
989
1143
|
|
|
990
|
-
[Unreleased]: https://github.com/grantrayner/abqari/compare/v1.
|
|
1144
|
+
[Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.0...HEAD
|
|
1145
|
+
[1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
|
|
1146
|
+
[1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
|
|
991
1147
|
[1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
|
|
992
1148
|
[1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
|
|
993
1149
|
[1.1.0]: https://github.com/grantrayner/abqari/compare/v1.0.0...v1.1.0
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ optional overrides), while the engine — layouts, themes, helpers, asset
|
|
|
11
11
|
pipeline — lives in the dependency. Forking the engine is also
|
|
12
12
|
supported for users who want to own all the code.
|
|
13
13
|
|
|
14
|
-
> **Status:** v1.
|
|
14
|
+
> **Status:** v1.3.0 — see
|
|
15
15
|
> [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md).
|
|
16
16
|
> Distributed via RubyGems (`gem install abqari`) and as a Bundler
|
|
17
17
|
> path/git source. The API follows [semver](https://semver.org/) from
|
|
@@ -174,7 +174,7 @@ Test that it worked:
|
|
|
174
174
|
abqari version
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
You should see a version number, e.g. `abqari 1.
|
|
177
|
+
You should see a version number, e.g. `abqari 1.3.0`. If you get
|
|
178
178
|
`command not found`, see [Troubleshooting](#troubleshooting) below.
|
|
179
179
|
|
|
180
180
|
### Step 3 — Create your first site
|
|
@@ -460,7 +460,7 @@ Edit `Gemfile`:
|
|
|
460
460
|
```ruby
|
|
461
461
|
source 'https://rubygems.org'
|
|
462
462
|
|
|
463
|
-
gem 'abqari', '~> 1.
|
|
463
|
+
gem 'abqari', '~> 1.3'
|
|
464
464
|
|
|
465
465
|
group :development do
|
|
466
466
|
gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
|
|
@@ -470,7 +470,7 @@ end
|
|
|
470
470
|
Or — pin to a git ref while tracking pre-release work:
|
|
471
471
|
|
|
472
472
|
```ruby
|
|
473
|
-
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.
|
|
473
|
+
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
|
|
474
474
|
```
|
|
475
475
|
|
|
476
476
|
Install the libvips C library once (hard dep for the image pipeline):
|
|
@@ -1631,6 +1631,8 @@ bin/fetch-fonts
|
|
|
1631
1631
|
|
|
1632
1632
|
Abqari downloads the woff2 files to `vendor/fonts/`, generates a `fonts.css` with rewritten URLs, and serves everything from `/assets/fonts/` at build time. The stylesheet is fingerprinted like any other asset (`/assets/fonts/fonts.<hash>.css`), so changing `fonts:` and re-running `bin/fetch-fonts` busts the immutable cache. The head partial links it whenever `fonts:` is configured and the fetch has run.
|
|
1633
1633
|
|
|
1634
|
+
`vendor/fonts/` is git-ignored, so every deploy has to fetch the fonts too, or the site ships in fallback fonts with a green build. The shipped GitHub Actions workflows run `bin/fetch-fonts` before `bin/build`; on a host that builds from its own command (Render), add it there. Or commit `vendor/fonts/` instead. See [deploying.md → Fonts](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#fonts).
|
|
1635
|
+
|
|
1634
1636
|
If you override the head partial, link the stylesheet with `<%= asset_path('fonts/fonts.css') %>`. An unhashed copy still ships at `/assets/fonts/fonts.css`, but `_headers` caches it for a year, so returning visitors won't see font changes.
|
|
1635
1637
|
|
|
1636
1638
|
## Icons
|
|
@@ -1763,7 +1765,7 @@ Every build emits the files a static host needs to serve a real site:
|
|
|
1763
1765
|
| `feed.xml` | RSS 2.0 feed of latest posts |
|
|
1764
1766
|
| `feed.json` | JSON Feed 1.1 |
|
|
1765
1767
|
| `robots.txt` | Per-environment — `Disallow: /` in dev/staging, real one in production |
|
|
1766
|
-
| `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify |
|
|
1768
|
+
| `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify. Render ignores it — `bundle exec abqari headers` prints the same rules to paste in (see [deploying.md](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#headers-render-ignores-_headers)) |
|
|
1767
1769
|
| `_redirects` | 301 mappings for any post with `redirect_from:` |
|
|
1768
1770
|
| `manifest.json` | Web app manifest, populated from your site title + locale. Override colours/names under `manifest:` in `site.yml` |
|
|
1769
1771
|
| `/.well-known/security.txt` | Generated when `security_contact:` is set |
|
|
@@ -1784,7 +1786,7 @@ Abqari generates a stub HTML page at `/old-tutorial/` with a `<meta refresh>` an
|
|
|
1784
1786
|
|
|
1785
1787
|
**SEO meta** — every page's `<head>` includes canonical URL, Open Graph tags, Twitter card tags, RSS/JSON Feed alternates, and (for posts) JSON-LD `Article` structured data. All driven from `site.config` and frontmatter — no manual tagging required.
|
|
1786
1788
|
|
|
1787
|
-
**Security headers** — the default `_headers` ships with a strict CSP (`'self'` only
|
|
1789
|
+
**Security headers** — the default `_headers` ships with a strict CSP (`'self'` only, with each of Abqari's few inline scripts pinned by SHA-256 rather than allowed by `'unsafe-inline'`), HSTS (1 year), `Referrer-Policy`, `Permissions-Policy` (camera/mic/geolocation off), `X-Content-Type-Options`, and `frame-ancestors 'none'`.
|
|
1788
1790
|
|
|
1789
1791
|
The policy is *built* from the features you've configured — enabling Plausible adds `plausible.io`, setting `newsletter.action` adds that provider's origin, and so on. You never hand-write it. Two keys extend it when the engine can't infer what you need:
|
|
1790
1792
|
|
|
@@ -1879,8 +1881,14 @@ for readers on slow or data-saving connections. Tune or disable:
|
|
|
1879
1881
|
speculation_rules:
|
|
1880
1882
|
enabled: true # false to omit entirely
|
|
1881
1883
|
eagerness: moderate # conservative / moderate / eager
|
|
1884
|
+
href_matches: /news/* # default: the posts collection (/posts/*); one pattern or a list
|
|
1882
1885
|
```
|
|
1883
1886
|
|
|
1887
|
+
Set `href_matches` when your main content isn't in `posts` — a site
|
|
1888
|
+
whose articles live in a `news` collection prerenders nothing until it
|
|
1889
|
+
does. The generated CSP pins the rules block by hash, so changing any
|
|
1890
|
+
of these changes the CSP; on a host that ignores `_headers`, re-copy it.
|
|
1891
|
+
|
|
1884
1892
|
Run `bin/audit` after building — it checks every page for missing
|
|
1885
1893
|
descriptions, broken links, missing alt text, and other SEO and
|
|
1886
1894
|
accessibility problems before you deploy.
|
|
@@ -2323,6 +2331,7 @@ Rebuilds the site, walks the output, and writes a markdown report to `audit/<YYY
|
|
|
2323
2331
|
| **Privacy** | External resource loads (URLs not on the site host) |
|
|
2324
2332
|
| **Dependencies** | Outdated gems via `bundle outdated`, CVEs via `bundle-audit` (if installed) |
|
|
2325
2333
|
| **Content** | Posts without descriptions, single-use tags, `series_position` problems that break prev/next silently (non-Integer, duplicate, or missing) |
|
|
2334
|
+
| **Security** | Inline code the built `_headers` CSP would block: `style="…"` attributes, `<style>` blocks, `on…=` handlers, and inline scripts whose hash isn't in `script-src`. Errors, because the page breaks on any host that enforces the policy |
|
|
2326
2335
|
|
|
2327
2336
|
The report opens with a summary table, a **Suggested fixes** code block listing every `bundle update <gem>` command needed to clear the dependency findings, and then groups findings by category with severity (`error`, `warn`, `info`) and a suggestion where applicable. Reports accumulate in `audit/` — useful to commit so you have a diffable history of site quality over time.
|
|
2328
2337
|
|
|
@@ -2357,7 +2366,7 @@ GitHub Dependabot does three things abqari's audit does **not**:
|
|
|
2357
2366
|
|
|
2358
2367
|
What `bin/audit` does that Dependabot doesn't:
|
|
2359
2368
|
|
|
2360
|
-
- **Same report covers links, SEO, accessibility, privacy, and
|
|
2369
|
+
- **Same report covers links, SEO, accessibility, privacy, content, and the CSP.** Dependabot only watches dependencies.
|
|
2361
2370
|
- **Uses the same `ruby-advisory-db`** Dependabot's Ruby ecosystem coverage uses, just at a single point in time.
|
|
2362
2371
|
- **Outputs to git-trackable markdown.** A diffable `audit/` history of every check you've run.
|
|
2363
2372
|
|
|
@@ -2416,7 +2425,7 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
|
|
|
2416
2425
|
|
|
2417
2426
|
## Livereload
|
|
2418
2427
|
|
|
2419
|
-
`bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, and `
|
|
2428
|
+
`bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, `data/`, and `plugins/` for changes. On change:
|
|
2420
2429
|
|
|
2421
2430
|
1. The build runs incrementally (no `_site/` wipe; mtime-aware copies skip unchanged work).
|
|
2422
2431
|
2. The build version bumps and the watcher classifies the change scope.
|
|
@@ -2424,6 +2433,8 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
|
|
|
2424
2433
|
- **Swaps the `<link rel="stylesheet">` href** in place if only CSS files changed (preserves scroll position and form state), or
|
|
2425
2434
|
- **Reloads the page** if anything else changed.
|
|
2426
2435
|
|
|
2436
|
+
**Ruby isn't reloaded.** Plugins, and anything they `require` from `lib/`, load once when `bin/serve` starts. When one of those files changes, the server logs a one-line warning naming it; restart `bin/serve` to load the new code. If a build fails while that code is stale, the failure output ends with the same reminder, because the usual symptom is `NoMethodError: undefined method '…' for an instance of Abqari::RenderContext` from a layout calling a helper the server hasn't loaded.
|
|
2437
|
+
|
|
2427
2438
|
**Watcher backend.** When the `listen` gem is installed (it's a development-group dep), the watcher uses native FS events (FSEvents on macOS, inotify on Linux) — zero overhead between changes. Without `listen`, it falls back to 500ms mtime polling. Both modes debounce events by 100ms so editor tools that fire two events per save don't double-rebuild.
|
|
2428
2439
|
|
|
2429
2440
|
**Transport.** Server-Sent Events (SSE) over plain HTTP — no WebSocket dep, works through any proxy. The browser opens a single persistent EventSource per tab; the server pushes events with sub-50ms median latency. Connection auto-reconnects if the dev server restarts. The script is only injected when `ABQARI_ENV=development`, so production builds are clean.
|
|
@@ -2444,6 +2455,8 @@ end
|
|
|
2444
2455
|
|
|
2445
2456
|
Lifecycle events: `:before_build`, `:after_load_pages`, `:after_generate`, `:after_build`. Filters: `:post_process(html, site, page)`. Full reference in [`docs/plugins.md`](https://github.com/grantrayner/abqari/blob/main/docs/plugins.md).
|
|
2446
2457
|
|
|
2458
|
+
`bin/serve` doesn't reload plugins, so restart it after editing one. It warns you when it sees the change (see [Livereload](#livereload)).
|
|
2459
|
+
|
|
2447
2460
|
## Performance
|
|
2448
2461
|
|
|
2449
2462
|
Build-time and runtime perf are already tuned for typical sites (~50–200 pages). What's baked in:
|
|
@@ -2546,13 +2559,14 @@ bin/audit # verify nothing regressed
|
|
|
2546
2559
|
```
|
|
2547
2560
|
|
|
2548
2561
|
`abqari new` scaffolds the Gemfile pinned to the current major.minor
|
|
2549
|
-
(`gem 'abqari', '~> 1.
|
|
2550
|
-
|
|
2551
|
-
|
|
2562
|
+
(`gem 'abqari', '~> 1.3'` as of this release). Bundler reads that as
|
|
2563
|
+
`>= 1.3, < 2.0`, so `bundle update abqari` takes minor and patch
|
|
2564
|
+
releases but never a major one, which is the release that can need
|
|
2565
|
+
changes to your site. To track a fork or an unreleased branch
|
|
2552
2566
|
instead, swap the line for a git source:
|
|
2553
2567
|
|
|
2554
2568
|
```ruby
|
|
2555
|
-
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.
|
|
2569
|
+
gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
|
|
2556
2570
|
```
|
|
2557
2571
|
|
|
2558
2572
|
The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
|
|
@@ -325,12 +325,14 @@
|
|
|
325
325
|
<% end %>
|
|
326
326
|
|
|
327
327
|
<%# Speculation Rules — prerender posts the user is likely to click
|
|
328
|
-
(any `/posts/<slug>/` link in the current document
|
|
329
|
-
browsers (Chrome,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
328
|
+
(any `/posts/<slug>/` link in the current document, or whatever
|
|
329
|
+
`speculation_rules.href_matches` names). Modern browsers (Chrome,
|
|
330
|
+
Edge) act on this for measurably-faster navigations; other
|
|
331
|
+
browsers safely ignore it. The body comes from
|
|
332
|
+
Abqari::SpeculationRules so the generated `_headers` CSP can pin
|
|
333
|
+
its exact SHA-256 — Chrome ignores `'inline-speculation-rules'`
|
|
334
|
+
once script-src carries hashes. Emit it byte-for-byte: any change
|
|
335
|
+
here (even whitespace) breaks the hash. Toggle off with
|
|
334
336
|
`speculation_rules.enabled: false` in config.
|
|
335
337
|
|
|
336
338
|
The CSP-pinned inline guard JS below honours `prefers-reduced-data`
|
|
@@ -339,18 +341,8 @@
|
|
|
339
341
|
can act on it. This avoids burning bandwidth on metered users.
|
|
340
342
|
The script gets its own SHA256 entry in HeadersFile so strict CSP
|
|
341
343
|
stays clean. %>
|
|
342
|
-
<%
|
|
343
|
-
|
|
344
|
-
<%
|
|
345
|
-
eagerness = %w[conservative moderate eager].include?(spec_cfg['eagerness'].to_s) ? spec_cfg['eagerness'].to_s : 'moderate'
|
|
346
|
-
posts_slug = site.collection_config('posts')['slug'].to_s
|
|
347
|
-
posts_path = posts_slug.empty? ? 'posts' : posts_slug
|
|
348
|
-
%>
|
|
349
|
-
<script type="speculationrules" id="abqari-spec-rules">
|
|
350
|
-
{ "prerender": [{ "source": "document",
|
|
351
|
-
"where": { "href_matches": "/<%= posts_path %>/*" },
|
|
352
|
-
"eagerness": "<%= eagerness %>" }] }
|
|
353
|
-
</script>
|
|
344
|
+
<% if Abqari::SpeculationRules.enabled?(site) %>
|
|
345
|
+
<script type="speculationrules" id="abqari-spec-rules"><%== Abqari::SpeculationRules.script(site) %></script>
|
|
354
346
|
<script><%== Abqari::SpeculationRulesGuard::SCRIPT %></script>
|
|
355
347
|
<% end %>
|
|
356
348
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'digest'
|
|
6
|
+
|
|
7
|
+
module Abqari
|
|
8
|
+
class Audit
|
|
9
|
+
# CSP rule. Checks every page's inline code against the
|
|
10
|
+
# Content-Security-Policy the same build wrote to `_headers`: inline
|
|
11
|
+
# `<script>` and `<style>` blocks, `style="…"` attributes and `on…=`
|
|
12
|
+
# event handlers.
|
|
13
|
+
#
|
|
14
|
+
# The policy is strict on purpose (`style-src 'self'`, scripts pinned
|
|
15
|
+
# by hash), so anything a template adds inline is blocked wherever
|
|
16
|
+
# the CSP is enforced — and nothing else notices. The dev server
|
|
17
|
+
# doesn't apply `_headers`, the page still renders, and the browser
|
|
18
|
+
# only logs a console error. One site carried 52 inline
|
|
19
|
+
# `style="--ratio: …"` attributes that would have collapsed every
|
|
20
|
+
# photo pane, and the engine's own speculation-rules block shipped in
|
|
21
|
+
# 1.1.x allowed only by a keyword Chrome ignores.
|
|
22
|
+
#
|
|
23
|
+
# Reads the BUILT `_headers`, not the engine's defaults, so a
|
|
24
|
+
# `headers:` override or `csp:` extension in site.yml is what gets
|
|
25
|
+
# checked. Error-severity: on a host that enforces the policy the
|
|
26
|
+
# page is broken. A site on a host that ignores `_headers` (GitHub
|
|
27
|
+
# Pages) will see these too; the code is still blocked the day the
|
|
28
|
+
# policy is applied.
|
|
29
|
+
class Csp < Rule
|
|
30
|
+
# Script types the browser executes, so CSP governs them. Any other
|
|
31
|
+
# `type` is a data block (JSON-LD, templates) and CSP ignores it.
|
|
32
|
+
EXECUTABLE_TYPES = %w[
|
|
33
|
+
text/javascript application/javascript application/ecmascript text/ecmascript
|
|
34
|
+
module importmap speculationrules
|
|
35
|
+
].freeze
|
|
36
|
+
HASH_ALGORITHMS = { 'sha256' => Digest::SHA256, 'sha384' => Digest::SHA384, 'sha512' => Digest::SHA512 }.freeze
|
|
37
|
+
|
|
38
|
+
# Most specific directive first, as browsers resolve them.
|
|
39
|
+
DIRECTIVES = {
|
|
40
|
+
script: %w[script-src-elem script-src default-src],
|
|
41
|
+
script_attr: %w[script-src-attr script-src default-src],
|
|
42
|
+
style: %w[style-src-elem style-src default-src],
|
|
43
|
+
style_attr: %w[style-src-attr style-src default-src]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
LABELS = {
|
|
47
|
+
script: 'inline <script>',
|
|
48
|
+
script_attr: 'inline event handler',
|
|
49
|
+
style: '<style> block',
|
|
50
|
+
style_attr: 'style attribute'
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
ADVICE = {
|
|
54
|
+
script: 'Move it to a file under app/assets/js/, or add its sha256 to the CSP. ' \
|
|
55
|
+
'For the engine\'s own scripts, upgrade abqari and re-copy the CSP.',
|
|
56
|
+
script_attr: 'Attach the handler from a script file with addEventListener instead.',
|
|
57
|
+
style: 'Move the rules into app/assets/css/site.css.',
|
|
58
|
+
style_attr: 'Move the styles into app/assets/css/site.css as a class, or a data ' \
|
|
59
|
+
'attribute matched by an attribute selector for per-element values.'
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
def run
|
|
63
|
+
policies = csp_policies
|
|
64
|
+
return if policies.empty?
|
|
65
|
+
|
|
66
|
+
html_files.each do |file|
|
|
67
|
+
url = page_url(file)
|
|
68
|
+
applicable = policies.select { |prefix, _| url.start_with?(prefix) }.map(&:last)
|
|
69
|
+
next if applicable.empty?
|
|
70
|
+
|
|
71
|
+
check_page(url, File.read(file, encoding: 'UTF-8'), applicable)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# `[[path_prefix, { directive => [sources] }]]` for every CSP rule in
|
|
78
|
+
# the built `_headers`. More than one can apply to a page; the
|
|
79
|
+
# browser enforces all of them, so inline code has to pass each.
|
|
80
|
+
def csp_policies
|
|
81
|
+
path = File.join(@site.output_dir, '_headers')
|
|
82
|
+
return [] unless File.file?(path)
|
|
83
|
+
|
|
84
|
+
HeadersFile.parse(File.read(path, encoding: 'UTF-8'))
|
|
85
|
+
.select { |rule| rule['name'].casecmp?('Content-Security-Policy') }
|
|
86
|
+
.map { |rule| [rule['path'].delete_suffix('*'), parse_policy(rule['value'])] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_policy(value)
|
|
90
|
+
value.split(';').each_with_object({}) do |directive, policy|
|
|
91
|
+
name, *sources = directive.strip.split(/\s+/)
|
|
92
|
+
policy[name.downcase] = sources if name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_page(url, html, policies)
|
|
97
|
+
blocked = Hash.new { |h, k| h[k] = [] }
|
|
98
|
+
inline_code(html).each do |kind, content, type|
|
|
99
|
+
policy = policies.find { |pol| !allowed?(pol, kind, content, type) }
|
|
100
|
+
blocked[kind] << [content, policy] if policy
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
blocked.each do |kind, hits|
|
|
104
|
+
content, policy = hits.first
|
|
105
|
+
directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
|
|
106
|
+
count = hits.size
|
|
107
|
+
add(category: 'security', severity: 'error', location: url,
|
|
108
|
+
message: "#{count} #{LABELS[kind]}#{'s' unless count == 1} blocked by the CSP " \
|
|
109
|
+
"(#{directive} #{policy[directive].join(' ')}), e.g. #{example(kind, content)}",
|
|
110
|
+
suggestion: ADVICE[kind])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Each piece of inline code as `[kind, content, script_type]`, where
|
|
115
|
+
# content is exactly what the browser hashes.
|
|
116
|
+
def inline_code(html)
|
|
117
|
+
html = html.gsub(/<!--.*?-->/m, '')
|
|
118
|
+
code = []
|
|
119
|
+
|
|
120
|
+
html.scan(%r{<script\b([^>]*)>(.*?)</script>}mi).each do |attrs, body|
|
|
121
|
+
next if attribute(attrs, 'src')
|
|
122
|
+
|
|
123
|
+
type = attribute(attrs, 'type')&.strip&.downcase
|
|
124
|
+
next unless type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
|
|
125
|
+
|
|
126
|
+
code << [:script, body, type]
|
|
127
|
+
end
|
|
128
|
+
html.scan(%r{<style\b[^>]*>(.*?)</style>}mi).each { |(body)| code << [:style, body] }
|
|
129
|
+
|
|
130
|
+
# Attributes, with script and style bodies removed first so a
|
|
131
|
+
# string inside a script that looks like markup isn't counted.
|
|
132
|
+
tags_only = html.gsub(%r{<(script|style)\b[^>]*>.*?</\1>}mi, '')
|
|
133
|
+
tags_only.scan(/<[a-zA-Z][^>]*>/).each do |tag|
|
|
134
|
+
tag.scan(/\s(on[a-z]+|style)\s*=\s*(?:"([^"]*)"|'([^']*)')/i).each do |name, dq, sq|
|
|
135
|
+
code << [name.casecmp?('style') ? :style_attr : :script_attr, CGI.unescapeHTML(dq || sq)]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
code
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def attribute(attrs, name)
|
|
142
|
+
match = attrs.match(/\b#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i)
|
|
143
|
+
match && (match[1] || match[2] || match[3])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Mirrors the CSP spec's inline checks: a hash or nonce in the list
|
|
147
|
+
# switches `'unsafe-inline'` off; attribute hashes count only with
|
|
148
|
+
# `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
|
|
149
|
+
# when the list has no hash or nonce: Chrome reports a block allowed
|
|
150
|
+
# by nothing but the keyword as a violation once hashes are present.
|
|
151
|
+
def allowed?(policy, kind, content, type = nil)
|
|
152
|
+
directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
|
|
153
|
+
return true unless directive
|
|
154
|
+
|
|
155
|
+
sources = policy[directive]
|
|
156
|
+
hashed = sources.any? { |s| s.match?(/\A'(sha256|sha384|sha512|nonce)-/i) }
|
|
157
|
+
hashed ||= kind == :script && sources.include?("'strict-dynamic'")
|
|
158
|
+
return true if sources.include?("'unsafe-inline'") && !hashed
|
|
159
|
+
return true if type == 'speculationrules' && sources.include?("'inline-speculation-rules'") && !hashed
|
|
160
|
+
return false if %i[style_attr script_attr].include?(kind) && !sources.include?("'unsafe-hashes'")
|
|
161
|
+
|
|
162
|
+
HASH_ALGORITHMS.any? do |alg, digest|
|
|
163
|
+
sources.include?("'#{alg}-#{Base64.strict_encode64(digest.digest(content))}'")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def example(kind, content)
|
|
168
|
+
snippet = content.strip.gsub(/\s+/, ' ')
|
|
169
|
+
snippet = "#{snippet[0, 60]}…" if snippet.length > 60
|
|
170
|
+
case kind
|
|
171
|
+
when :style_attr then %(style="#{snippet}")
|
|
172
|
+
when :script_attr then %(handler "#{snippet}")
|
|
173
|
+
else %(`#{snippet}`)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/abqari/audit.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative 'audit/privacy'
|
|
|
12
12
|
require_relative 'audit/performance'
|
|
13
13
|
require_relative 'audit/dependencies'
|
|
14
14
|
require_relative 'audit/content'
|
|
15
|
+
require_relative 'audit/csp'
|
|
15
16
|
|
|
16
17
|
module Abqari
|
|
17
18
|
# Site audit orchestrator. Each check lives in its own rule class
|
|
@@ -36,14 +37,15 @@ module Abqari
|
|
|
36
37
|
'privacy' => 'Privacy',
|
|
37
38
|
'perf' => 'Performance',
|
|
38
39
|
'deps' => 'Dependencies',
|
|
39
|
-
'content' => 'Content'
|
|
40
|
+
'content' => 'Content',
|
|
41
|
+
'security' => 'Security'
|
|
40
42
|
}.freeze
|
|
41
43
|
|
|
42
44
|
# Rule classes, in execution order. Order matters only for the
|
|
43
45
|
# output report — findings are grouped by category, not insertion
|
|
44
46
|
# order — but Links first keeps the report scan-friendly: errors
|
|
45
47
|
# appear before warnings.
|
|
46
|
-
RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content].freeze
|
|
48
|
+
RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content, Csp].freeze
|
|
47
49
|
|
|
48
50
|
attr_reader :findings
|
|
49
51
|
|