e6afa91e09
kage renders every page in headless Chrome, snapshots the final DOM, strips all JavaScript, and localises CSS, images, and fonts so a site can be browsed offline as a plain folder of files. The engine is split into small packages: urlx deterministic URL to local-path mapping and scope rules sanitize remove scripts, on* handlers, and javascript: URLs asset rewrite HTML and CSS references, download assets browser headless Chrome pool over the DevTools protocol robots robots.txt matcher clone the orchestrator: a polite resumable breadth-first crawl The cli package wires a cobra and fang command surface with two commands, clone and serve. Every pure package has table tests; the browser and clone packages add Chrome-driven end-to-end tests that skip when no browser is present or under -short. CI runs gofmt, vet, build, race tests, golangci-lint, govulncheck, and a tidy check on Linux and macOS. A goreleaser config fans one tag out to archives, deb/rpm/apk, a Chromium-bundled GHCR image, and the package managers. A tago docs site builds to Pages and Cloudflare.
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
// Package sanitize removes every trace of JavaScript from an HTML document so
|
|
// the saved page is inert: a photograph, not a program.
|
|
//
|
|
// It parses with golang.org/x/net/html, walks the tree, and deletes scripts,
|
|
// event handlers, javascript: URLs, and the dead preconnect/preload hints that
|
|
// mean nothing offline — while leaving styles, images, fonts, forms, and all
|
|
// semantic markup untouched so the layout survives intact.
|
|
package sanitize
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
// Options tune a few edge behaviours; the zero value is the safe default
|
|
// (scripts and noscript removed, meta-refresh removed).
|
|
type Options struct {
|
|
// KeepNoscript unwraps <noscript> content into the document instead of
|
|
// deleting it, for sites whose real content hides behind a JS check.
|
|
KeepNoscript bool
|
|
// KeepMetaRefresh preserves a plain timed <meta http-equiv="refresh">
|
|
// (a JS-target refresh is always removed).
|
|
KeepMetaRefresh bool
|
|
// Banner, when non-empty, is inserted as an HTML comment at the top of the
|
|
// document.
|
|
Banner string
|
|
}
|
|
|
|
// Report counts what was removed, for the run summary and for tests.
|
|
type Report struct {
|
|
ScriptsRemoved int
|
|
HandlersRemoved int
|
|
NoscriptRemoved int
|
|
NoscriptUnwrapped int
|
|
JSURLsNeutralized int
|
|
MetaRefreshRemoved int
|
|
DeadLinksRemoved int
|
|
}
|
|
|
|
// jsURLAttrs are attributes whose value may be a javascript: URL.
|
|
var jsURLAttrs = map[string]bool{
|
|
"href": true, "src": true, "action": true, "formaction": true,
|
|
"poster": true, "data": true, "background": true, "xlink:href": true,
|
|
}
|
|
|
|
// Strip parses doc, removes all JavaScript, and returns the rewritten HTML plus
|
|
// a Report. A parse error is returned unchanged to the caller.
|
|
func Strip(doc []byte, opts Options) ([]byte, Report, error) {
|
|
root, err := html.Parse(bytes.NewReader(doc))
|
|
if err != nil {
|
|
return nil, Report{}, err
|
|
}
|
|
rep := CleanTree(root, opts)
|
|
var buf bytes.Buffer
|
|
if err := html.Render(&buf, root); err != nil {
|
|
return nil, rep, err
|
|
}
|
|
return buf.Bytes(), rep, nil
|
|
}
|
|
|
|
// CleanTree removes all JavaScript from an already-parsed document in place and
|
|
// returns the Report. The cloner uses this so the HTML is parsed only once and
|
|
// shared with the asset rewriter.
|
|
func CleanTree(root *html.Node, opts Options) Report {
|
|
var rep Report
|
|
clean(root, opts, &rep)
|
|
if opts.Banner != "" {
|
|
insertBanner(root, opts.Banner)
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// clean walks n's children, removing or scrubbing each before recursing.
|
|
func clean(n *html.Node, opts Options, rep *Report) {
|
|
var next *html.Node
|
|
for c := n.FirstChild; c != nil; c = next {
|
|
next = c.NextSibling
|
|
if c.Type == html.ElementNode {
|
|
switch c.DataAtom {
|
|
case atom.Script:
|
|
n.RemoveChild(c)
|
|
rep.ScriptsRemoved++
|
|
continue
|
|
case atom.Noscript:
|
|
if opts.KeepNoscript {
|
|
unwrapNoscript(n, c)
|
|
rep.NoscriptUnwrapped++
|
|
} else {
|
|
n.RemoveChild(c)
|
|
rep.NoscriptRemoved++
|
|
}
|
|
continue
|
|
case atom.Meta:
|
|
if isMetaRefresh(c) && (!opts.KeepMetaRefresh || isJSRefresh(c)) {
|
|
n.RemoveChild(c)
|
|
rep.MetaRefreshRemoved++
|
|
continue
|
|
}
|
|
case atom.Link:
|
|
if isDeadLink(c) {
|
|
n.RemoveChild(c)
|
|
rep.DeadLinksRemoved++
|
|
continue
|
|
}
|
|
}
|
|
stripHandlers(c, rep)
|
|
neutralizeJSURLs(c, rep)
|
|
}
|
|
clean(c, opts, rep)
|
|
}
|
|
}
|
|
|
|
// stripHandlers removes every on* event-handler attribute from n.
|
|
func stripHandlers(n *html.Node, rep *Report) {
|
|
kept := n.Attr[:0]
|
|
for _, a := range n.Attr {
|
|
if len(a.Key) > 2 && strings.HasPrefix(strings.ToLower(a.Key), "on") {
|
|
rep.HandlersRemoved++
|
|
continue
|
|
}
|
|
kept = append(kept, a)
|
|
}
|
|
n.Attr = kept
|
|
}
|
|
|
|
// neutralizeJSURLs replaces javascript: URLs: links become "#", other carriers
|
|
// lose the attribute entirely.
|
|
func neutralizeJSURLs(n *html.Node, rep *Report) {
|
|
kept := n.Attr[:0]
|
|
for _, a := range n.Attr {
|
|
key := strings.ToLower(a.Key)
|
|
if jsURLAttrs[key] && strings.HasPrefix(strings.ToLower(strings.TrimSpace(a.Val)), "javascript:") {
|
|
rep.JSURLsNeutralized++
|
|
if key == "href" {
|
|
a.Val = "#"
|
|
kept = append(kept, a)
|
|
}
|
|
// non-href carriers: drop the attribute.
|
|
continue
|
|
}
|
|
kept = append(kept, a)
|
|
}
|
|
n.Attr = kept
|
|
}
|
|
|
|
// isMetaRefresh reports whether n is a <meta http-equiv="refresh">.
|
|
func isMetaRefresh(n *html.Node) bool {
|
|
return strings.EqualFold(attr(n, "http-equiv"), "refresh")
|
|
}
|
|
|
|
// isJSRefresh reports whether a meta-refresh target is a javascript: URL.
|
|
func isJSRefresh(n *html.Node) bool {
|
|
return strings.Contains(strings.ToLower(attr(n, "content")), "javascript:")
|
|
}
|
|
|
|
// isDeadLink reports whether a <link> is a resource hint that is useless or
|
|
// script-bound offline: preconnect, dns-prefetch, modulepreload, or a
|
|
// preload/prefetch that targets a script.
|
|
func isDeadLink(n *html.Node) bool {
|
|
for r := range strings.FieldsSeq(strings.ToLower(attr(n, "rel"))) {
|
|
switch r {
|
|
case "preconnect", "dns-prefetch", "modulepreload":
|
|
return true
|
|
case "preload", "prefetch":
|
|
as := strings.ToLower(attr(n, "as"))
|
|
href := strings.ToLower(attr(n, "href"))
|
|
if as == "script" || strings.HasSuffix(href, ".js") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// unwrapNoscript replaces a <noscript> with its content. Because x/net/html
|
|
// parses noscript content as raw text (scripting enabled), the text is
|
|
// re-parsed as a fragment in the parent's context and spliced in before the
|
|
// noscript node, which is then removed.
|
|
func unwrapNoscript(parent, ns *html.Node) {
|
|
var raw strings.Builder
|
|
for c := ns.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode {
|
|
raw.WriteString(c.Data)
|
|
}
|
|
}
|
|
frag, err := html.ParseFragment(strings.NewReader(raw.String()), &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: "body",
|
|
DataAtom: atom.Body,
|
|
})
|
|
if err == nil {
|
|
for _, fn := range frag {
|
|
parent.InsertBefore(fn, ns)
|
|
}
|
|
}
|
|
parent.RemoveChild(ns)
|
|
}
|
|
|
|
// insertBanner prepends an HTML comment to the document.
|
|
func insertBanner(root *html.Node, text string) {
|
|
c := &html.Node{Type: html.CommentNode, Data: " " + text + " "}
|
|
if root.FirstChild != nil {
|
|
root.InsertBefore(c, root.FirstChild)
|
|
} else {
|
|
root.AppendChild(c)
|
|
}
|
|
}
|
|
|
|
// attr returns the value of n's attribute key (case-insensitive), or "".
|
|
func attr(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if strings.EqualFold(a.Key, key) {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|