@axis-network/butter-col 0.0.0-stage → 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/bin/butter-col.js +4 -0
  4. package/dist/bootstrap.html +67 -0
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +7 -0
  7. package/dist/controller.mjs +2 -0
  8. package/dist/controller.mjs.map +7 -0
  9. package/dist/formatter.mjs +2 -0
  10. package/dist/formatter.mjs.map +7 -0
  11. package/dist/sw.js +30 -0
  12. package/dist/sw.js.map +7 -0
  13. package/dist/transports/butter.js +2 -0
  14. package/dist/transports/butter.js.map +7 -0
  15. package/dist/types/controller/index.d.ts +65 -0
  16. package/dist/types/formatter/codecs.d.ts +17 -0
  17. package/dist/types/formatter/css.d.ts +10 -0
  18. package/dist/types/formatter/html.d.ts +61 -0
  19. package/dist/types/formatter/index.d.ts +77 -0
  20. package/dist/types/formatter/js.d.ts +27 -0
  21. package/dist/types/formatter/url.d.ts +40 -0
  22. package/lib/cli.js +257 -0
  23. package/lib/index.d.ts +68 -0
  24. package/lib/index.js +166 -0
  25. package/package.json +77 -4
  26. package/server/conf/butter-proxy.conf.template +29 -0
  27. package/server/conf/mime.types +19 -0
  28. package/server/conf/nginx.conf.template +152 -0
  29. package/server/config.lua +34 -0
  30. package/server/lua/butter/config.lua +160 -0
  31. package/server/lua/butter/cookiejar.lua +197 -0
  32. package/server/lua/butter/dns.lua +67 -0
  33. package/server/lua/butter/guard.lua +78 -0
  34. package/server/lua/butter/headers.lua +83 -0
  35. package/server/lua/butter/init.lua +18 -0
  36. package/server/lua/butter/ip.lua +165 -0
  37. package/server/lua/butter/limit.lua +18 -0
  38. package/server/lua/butter/session.lua +42 -0
  39. package/server/lua/butter/store.lua +77 -0
  40. package/server/lua/butter/transport.lua +248 -0
  41. package/server/lua/butter/url.lua +78 -0
  42. package/site/app.css +373 -0
  43. package/site/app.js +335 -0
  44. package/site/butter-col.png +0 -0
  45. package/site/index.html +139 -0
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@axis-network/butter-col",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A service-worker web proxy with pluggable transports and an OpenResty (Lua) backend.",
5
+ "keywords": [
6
+ "butter-col",
7
+ "proxy",
8
+ "web-proxy",
9
+ "service-worker",
10
+ "openresty",
11
+ "transport"
12
+ ],
13
+ "author": "Axis Network",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/TheAxis-Network/butter-col.git",
18
+ "directory": "packages/butter-col"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/TheAxis-Network/butter-col/issues"
22
+ },
23
+ "homepage": "https://github.com/TheAxis-Network/butter-col#readme",
24
+ "type": "module",
25
+ "main": "./lib/index.js",
26
+ "types": "./lib/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./lib/index.d.ts",
30
+ "import": "./lib/index.js"
31
+ },
32
+ "./formatter": {
33
+ "types": "./dist/types/formatter/index.d.ts",
34
+ "import": "./dist/formatter.mjs"
35
+ },
36
+ "./controller": {
37
+ "types": "./dist/types/controller/index.d.ts",
38
+ "import": "./dist/controller.mjs"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "bin": {
43
+ "butter-col": "bin/butter-col.js"
44
+ },
45
+ "files": [
46
+ "bin",
47
+ "lib",
48
+ "dist",
49
+ "site",
50
+ "LICENSE",
51
+ "server/conf/nginx.conf.template",
52
+ "server/conf/butter-proxy.conf.template",
53
+ "server/conf/mime.types",
54
+ "server/lua",
55
+ "server/config.lua",
56
+ "README.md"
57
+ ],
58
+ "engines": {
59
+ "node": ">=20"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "scripts": {
65
+ "build": "node scripts/build.mjs",
66
+ "dev": "node scripts/build.mjs --watch",
67
+ "typecheck": "tsc -p tsconfig.json",
68
+ "test": "node --test test/*.test.mjs",
69
+ "test:lua": "node test/lua/run.mjs"
70
+ },
71
+ "dependencies": {
72
+ "@axis-network/butter-transport": "^0.1.0"
73
+ },
74
+ "devDependencies": {
75
+ "esbuild": "^0.28.2",
76
+ "fengari": "^0.1.5",
77
+ "typescript": "^7.0.2"
78
+ }
79
+ }
@@ -0,0 +1,29 @@
1
+ # Rendered by `butter-col server` into <runtime>/conf/butter-proxy.conf ({{CACERT}} = CA bundle path).
2
+ # Upstream settings shared by /butter/fetch and /butter/ws.
3
+ # The Lua access handler fills in the $butter_* variables after validating
4
+ # the target and resolving it to an address that passed the SSRF guard.
5
+
6
+ proxy_pass $butter_scheme://$butter_addr$butter_uri;
7
+ proxy_http_version 1.1;
8
+ proxy_set_header Host $butter_host;
9
+
10
+ proxy_ssl_server_name on;
11
+ proxy_ssl_name $butter_sni;
12
+ proxy_ssl_protocols TLSv1.2 TLSv1.3;
13
+ proxy_ssl_verify on;
14
+ proxy_ssl_verify_depth 5;
15
+ proxy_ssl_trusted_certificate {{CACERT}};
16
+ proxy_ssl_session_reuse on;
17
+
18
+ # Never let an upstream steer nginx (X-Accel-Redirect would be an internal redirect).
19
+ proxy_ignore_headers X-Accel-Redirect X-Accel-Expires X-Accel-Limit-Rate X-Accel-Buffering X-Accel-Charset;
20
+
21
+ proxy_redirect off;
22
+ proxy_buffering off;
23
+ proxy_intercept_errors off;
24
+ proxy_connect_timeout 15s;
25
+
26
+ # Room for large upstream response headers (big CSPs, many cookies).
27
+ proxy_buffer_size 64k;
28
+ proxy_buffers 16 64k;
29
+ proxy_busy_buffers_size 128k;
@@ -0,0 +1,19 @@
1
+ types {
2
+ text/html html htm;
3
+ text/css css;
4
+ text/javascript js mjs;
5
+ application/json json map;
6
+ application/manifest+json webmanifest;
7
+ application/wasm wasm;
8
+ text/plain txt;
9
+ text/markdown md;
10
+ image/svg+xml svg svgz;
11
+ image/png png;
12
+ image/jpeg jpg jpeg;
13
+ image/gif gif;
14
+ image/webp webp;
15
+ image/avif avif;
16
+ image/x-icon ico;
17
+ font/woff woff;
18
+ font/woff2 woff2;
19
+ }
@@ -0,0 +1,152 @@
1
+ # butter-col: OpenResty configuration template.
2
+ #
3
+ # `butter-col server start` renders this into <runtime>/conf/nginx.conf,
4
+ # replacing each double-brace placeholder with an absolute path or value:
5
+ #
6
+ # {{LISTEN}} listen address, e.g. 8080
7
+ # {{LUA_DIR}} this package's server/lua
8
+ # {{CONFIG_FILE}} your butter.config.lua (or the package default)
9
+ # {{SITE_ROOT}} the site served at / (launcher, docs, your app)
10
+ # {{APP_DIR}} this package's built-in frontend (site/), served at /app/
11
+ # {{DIST_DIR}} this package's dist (service worker, runtime, ...)
12
+ # {{RUNTIME_DIR}} the runtime directory (logs, transports, cacert.pem)
13
+ #
14
+ # The /butter/ paths below must match `base` in the Lua configuration.
15
+
16
+ worker_processes auto;
17
+ error_log logs/error.log notice;
18
+ pid logs/nginx.pid;
19
+
20
+ events {
21
+ worker_connections 4096;
22
+ }
23
+
24
+ http {
25
+ include mime.types;
26
+ default_type application/octet-stream;
27
+ charset utf-8;
28
+ charset_types text/css text/plain text/javascript application/javascript application/json image/svg+xml;
29
+ sendfile on;
30
+ server_tokens off;
31
+
32
+ # Target URLs travel in headers, so access logs never contain browsing history.
33
+ access_log logs/access.log;
34
+
35
+ lua_package_path "{{LUA_DIR}}/?.lua;{{LUA_DIR}}/?/init.lua;;";
36
+ lua_shared_dict butter_dns 10m;
37
+ lua_shared_dict butter_cookies 64m;
38
+ lua_shared_dict butter_limit 10m;
39
+
40
+ init_by_lua_block {
41
+ require("butter").init([==[{{CONFIG_FILE}}]==])
42
+ }
43
+
44
+ # X-Butter-Headers carries the page's request headers as JSON.
45
+ large_client_header_buffers 8 64k;
46
+ client_max_body_size 100m;
47
+
48
+ server {
49
+ listen {{LISTEN}};
50
+ server_name _;
51
+
52
+ root {{SITE_ROOT}};
53
+ index index.html;
54
+
55
+ location / {
56
+ try_files $uri $uri/ =404;
57
+ }
58
+
59
+ # The built-in frontend, always available (also served at / when no
60
+ # --site is given).
61
+ location = /app {
62
+ return 301 /app/;
63
+ }
64
+ location ^~ /app/ {
65
+ alias {{APP_DIR}}/;
66
+ index index.html;
67
+ add_header Cache-Control "no-cache" always;
68
+ }
69
+
70
+ # --- butter-col endpoints --------------------------------------------
71
+
72
+ location = /butter/sw.js {
73
+ alias {{DIST_DIR}}/sw.js;
74
+ add_header Service-Worker-Allowed "/" always;
75
+ add_header Cache-Control "no-cache" always;
76
+ }
77
+
78
+ location = /butter/config.js {
79
+ content_by_lua_block { require("butter.transport").config_js() }
80
+ }
81
+
82
+ location = /butter/health {
83
+ content_by_lua_block { require("butter.transport").health() }
84
+ }
85
+
86
+ location = /butter/cookie {
87
+ client_max_body_size 16k;
88
+ client_body_buffer_size 16k;
89
+ content_by_lua_block { require("butter.transport").cookie() }
90
+ }
91
+
92
+ location = /butter/fetch {
93
+ set $butter_scheme "";
94
+ set $butter_addr "";
95
+ set $butter_uri "";
96
+ set $butter_host "";
97
+ set $butter_sni "";
98
+ access_by_lua_block { require("butter.transport").access_fetch() }
99
+
100
+ include butter-proxy.conf;
101
+ proxy_set_header Connection "";
102
+ proxy_read_timeout 120s;
103
+ proxy_send_timeout 60s;
104
+
105
+ header_filter_by_lua_block { require("butter.transport").header_filter_fetch() }
106
+ error_page 502 504 @butter_upstream_error;
107
+ }
108
+
109
+ location = /butter/ws {
110
+ set $butter_scheme "";
111
+ set $butter_addr "";
112
+ set $butter_uri "";
113
+ set $butter_host "";
114
+ set $butter_sni "";
115
+ access_by_lua_block { require("butter.transport").access_ws() }
116
+
117
+ include butter-proxy.conf;
118
+ proxy_set_header Upgrade $http_upgrade;
119
+ proxy_set_header Connection "upgrade";
120
+ proxy_read_timeout 1h;
121
+ proxy_send_timeout 1h;
122
+
123
+ header_filter_by_lua_block { require("butter.transport").header_filter_ws() }
124
+ error_page 502 504 @butter_upstream_error;
125
+ }
126
+
127
+ location @butter_upstream_error {
128
+ content_by_lua_block { require("butter.transport").upstream_error() }
129
+ }
130
+
131
+ # Transport scripts: the built-in ones plus any added with
132
+ # `butter-col transport add`, copied here at startup.
133
+ location ^~ /butter/transports/ {
134
+ root {{RUNTIME_DIR}}/www;
135
+ add_header Cache-Control "no-cache" always;
136
+ }
137
+
138
+ # Proxied URLs only reach the server when the service worker is not
139
+ # running yet; the bootstrap page installs it and reloads.
140
+ location ^~ /butter/go/ {
141
+ root {{DIST_DIR}};
142
+ rewrite ^ /bootstrap.html break;
143
+ add_header Cache-Control "no-store" always;
144
+ }
145
+
146
+ # Built assets: client.js, controller.mjs, formatter.mjs, source maps.
147
+ location ^~ /butter/ {
148
+ alias {{DIST_DIR}}/;
149
+ add_header Cache-Control "no-cache" always;
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,34 @@
1
+ -- butter-col server configuration (the package default).
2
+ -- Copy this file to butter.config.lua in your project and edit it; anything
3
+ -- left out falls back to the defaults in lua/butter/config.lua.
4
+ -- Run `butter-col server reload` after editing.
5
+
6
+ return {
7
+ -- URL codec for proxied URLs: "none", "plain", "xor" or "base64".
8
+ codec = "plain",
9
+
10
+ -- Transports offered to browsers; visitors can switch between them with
11
+ -- ButterController.setTransport(). Add more with `butter-col transport add`.
12
+ transports = {
13
+ { name = "butter" }, -- built in: this server
14
+ -- { name = "bare", options = { server = "https://bare.example.com/" } },
15
+ },
16
+ default_transport = "butter",
17
+
18
+ dns = {
19
+ nameservers = { "1.1.1.1", "8.8.8.8" },
20
+ },
21
+
22
+ guard = {
23
+ -- Never set this to true on a server other people can reach: it lets
24
+ -- visitors reach your local network and cloud metadata endpoints.
25
+ allow_private = false,
26
+ blocked_hosts = { "localhost", "local", "internal", "localdomain", "home.arpa" },
27
+ },
28
+
29
+ rate_limit = {
30
+ enabled = true,
31
+ requests = 1200,
32
+ window = 60,
33
+ },
34
+ }
@@ -0,0 +1,160 @@
1
+ -- Configuration: defaults merged with server/config.lua (if present).
2
+
3
+ local M = {}
4
+
5
+ M.VERSION = "0.1.0"
6
+
7
+ local CODECS = { none = true, plain = true, xor = true, base64 = true }
8
+
9
+ local defaults = {
10
+ -- Where butter-col's own endpoints and assets live. The nginx locations in
11
+ -- conf/nginx.conf use this path too; change both together.
12
+ base = "/butter/",
13
+ -- Prefix of proxied URLs; must live under `base` (see the bootstrap location).
14
+ prefix = "/butter/go/",
15
+ -- URL codec: "none" | "plain" | "xor" | "base64"
16
+ codec = "plain",
17
+
18
+ -- Transports offered to browsers. Each entry: { name, url?, options? }.
19
+ -- `url` defaults to <base>transports/<name>.js; `options` is sent to the
20
+ -- transport's factory as JSON. Visitors can switch between these with
21
+ -- ButterController.setTransport().
22
+ transports = {
23
+ { name = "butter" },
24
+ },
25
+ -- Used until a visitor picks another one.
26
+ default_transport = "butter",
27
+
28
+ -- Reject transport requests that a browser marks as cross-site, so other
29
+ -- websites cannot use visitors' browsers to drive the proxy.
30
+ require_same_origin = true,
31
+ -- Allow clients that send neither Sec-Fetch-Site nor Origin (curl, scripts).
32
+ allow_non_browser = true,
33
+ -- Override the upstream User-Agent (nil forwards the browser's own).
34
+ user_agent = nil,
35
+
36
+ dns = {
37
+ nameservers = { "1.1.1.1", "8.8.8.8" },
38
+ timeout = 2000, -- ms per attempt
39
+ retrans = 2,
40
+ ipv6 = false, -- also look up AAAA records when there is no A record
41
+ min_ttl = 30,
42
+ max_ttl = 3600,
43
+ negative_ttl = 30,
44
+ },
45
+
46
+ guard = {
47
+ -- Allow upstream connections to private / loopback / link-local addresses.
48
+ -- Leave this off unless the proxy only serves you on a trusted network.
49
+ allow_private = false,
50
+ -- Allow the ports browsers refuse to fetch (SMTP, IRC, ...).
51
+ allow_bad_ports = false,
52
+ -- Hosts that may never be proxied; "example.com" also covers subdomains.
53
+ blocked_hosts = { "localhost", "local", "internal", "localdomain", "home.arpa" },
54
+ -- If set, only these hosts (and their subdomains) may be proxied.
55
+ allowed_hosts = nil,
56
+ },
57
+
58
+ cookies = {
59
+ enabled = true,
60
+ max_per_session = 500,
61
+ session_ttl = 7 * 24 * 3600, -- seconds a jar is kept after its last change
62
+ },
63
+
64
+ rate_limit = {
65
+ enabled = true,
66
+ requests = 1200, -- per client IP ...
67
+ window = 60, -- ... per this many seconds
68
+ },
69
+ }
70
+
71
+ local current
72
+
73
+ local function is_array(t)
74
+ return type(t) == "table" and t[1] ~= nil
75
+ end
76
+
77
+ --- Deep-merge `override` into `base`. Lists (and scalars) replace wholesale.
78
+ local function merge(base, override)
79
+ if override == nil then return base end
80
+ if type(base) ~= "table" or type(override) ~= "table" or is_array(base) or is_array(override) then
81
+ return override
82
+ end
83
+ local out = {}
84
+ for k, v in pairs(base) do out[k] = v end
85
+ for k, v in pairs(override) do out[k] = merge(base[k], v) end
86
+ return out
87
+ end
88
+
89
+ local function validate(cfg)
90
+ assert(type(cfg.base) == "string" and cfg.base:match("^/.*/$"), "config.base must start and end with '/'")
91
+ assert(type(cfg.prefix) == "string" and cfg.prefix:match("^/.*/$"), "config.prefix must start and end with '/'")
92
+ assert(cfg.prefix:sub(1, #cfg.base) == cfg.base and cfg.prefix ~= cfg.base, "config.prefix must live under config.base")
93
+ assert(CODECS[cfg.codec], "config.codec must be one of none, plain, xor, base64")
94
+ assert(type(cfg.dns.nameservers) == "table" and #cfg.dns.nameservers > 0, "config.dns.nameservers must not be empty")
95
+ assert(cfg.rate_limit.window > 0 and cfg.rate_limit.requests > 0, "config.rate_limit values must be positive")
96
+
97
+ assert(type(cfg.transports) == "table" and #cfg.transports > 0, "config.transports must list at least one transport")
98
+ local seen = {}
99
+ for i, entry in ipairs(cfg.transports) do
100
+ assert(type(entry) == "table", "config.transports[" .. i .. "] must be a table")
101
+ assert(type(entry.name) == "string" and entry.name:match("^[a-z0-9][a-z0-9._-]*$") and #entry.name <= 64,
102
+ "config.transports[" .. i .. "].name must be lower-case letters, digits, '.', '_' or '-'")
103
+ assert(not seen[entry.name], "config.transports lists \"" .. entry.name .. "\" twice")
104
+ assert(entry.url == nil or type(entry.url) == "string", "config.transports[" .. i .. "].url must be a string")
105
+ seen[entry.name] = true
106
+ end
107
+ assert(seen[cfg.default_transport], "config.default_transport must name one of config.transports")
108
+ end
109
+
110
+ --- Load defaults merged with the user file at `path` (missing file = defaults).
111
+ function M.load(path)
112
+ local user = {}
113
+ if path then
114
+ local chunk, err = loadfile(path)
115
+ if not chunk and not tostring(err):find("cannot open", 1, true) then
116
+ error("butter: cannot parse " .. path .. ": " .. tostring(err))
117
+ end
118
+ if chunk then
119
+ local ok, result = pcall(chunk)
120
+ if not ok then error("butter: error in " .. path .. ": " .. tostring(result)) end
121
+ if type(result) ~= "table" then error("butter: " .. path .. " must return a table") end
122
+ user = result
123
+ end
124
+ end
125
+ local cfg = merge(defaults, user)
126
+ validate(cfg)
127
+ current = cfg
128
+ return cfg
129
+ end
130
+
131
+ function M.get()
132
+ return current or M.load()
133
+ end
134
+
135
+ --- The subset sent to browsers as /butter/config.js.
136
+ function M.public()
137
+ local cfg = M.get()
138
+ local base = cfg.base
139
+ local transports = {}
140
+ for i, entry in ipairs(cfg.transports) do
141
+ transports[i] = {
142
+ name = entry.name,
143
+ url = entry.url or (base .. "transports/" .. entry.name .. ".js"),
144
+ options = entry.options,
145
+ }
146
+ end
147
+ return {
148
+ version = M.VERSION,
149
+ prefix = cfg.prefix,
150
+ base = base,
151
+ codec = cfg.codec,
152
+ transport = { fetch = base .. "fetch", ws = base .. "ws", cookie = base .. "cookie" },
153
+ client = base .. "client.js",
154
+ config = base .. "config.js",
155
+ transports = transports,
156
+ defaultTransport = cfg.default_transport,
157
+ }
158
+ end
159
+
160
+ return M
@@ -0,0 +1,197 @@
1
+ -- Server-side cookie jar logic (RFC 6265 subset). Pure Lua, no ngx dependency:
2
+ -- storage lives in butter.store; this module only transforms cookie lists.
3
+ --
4
+ -- A cookie is a table:
5
+ -- { name, value, domain, host_only, path, secure, http_only, same_site,
6
+ -- expires (epoch seconds or nil for session cookies), created }
7
+
8
+ local url_util = require "butter.url"
9
+
10
+ local M = {}
11
+
12
+ local function trim(s)
13
+ return (s:gsub("^%s+", ""):gsub("%s+$", ""))
14
+ end
15
+
16
+ local MONTHS = { jan = 1, feb = 2, mar = 3, apr = 4, may = 5, jun = 6, jul = 7, aug = 8, sep = 9, oct = 10, nov = 11, dec = 12 }
17
+
18
+ local function days_from_civil(y, m, d)
19
+ if m <= 2 then y = y - 1 end
20
+ local era = math.floor(y / 400)
21
+ local yoe = y - era * 400
22
+ local mp = (m + 9) % 12
23
+ local doy = math.floor((153 * mp + 2) / 5) + d - 1
24
+ local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
25
+ return era * 146097 + doe - 719468
26
+ end
27
+
28
+ --- Parse a cookie date (RFC 6265 section 5.1.1). Returns epoch seconds or nil.
29
+ function M.parse_date(s)
30
+ local hour, minute, second, day, month, year
31
+ for token in s:gmatch("[^%s,;%-/]+") do
32
+ local h, mi, se = token:match("^(%d%d?):(%d%d?):(%d%d?)")
33
+ if h and not hour then
34
+ hour, minute, second = tonumber(h), tonumber(mi), tonumber(se)
35
+ elseif not day and token:match("^%d%d?$") then
36
+ day = tonumber(token)
37
+ elseif not month and MONTHS[token:sub(1, 3):lower()] then
38
+ month = MONTHS[token:sub(1, 3):lower()]
39
+ elseif not year and token:match("^%d%d%d?%d?$") then
40
+ year = tonumber(token)
41
+ end
42
+ end
43
+ if not (hour and day and month and year) then return nil end
44
+ if year >= 70 and year <= 99 then year = year + 1900 elseif year < 70 then year = year + 2000 end
45
+ if day < 1 or day > 31 or year < 1601 or hour > 23 or minute > 59 or second > 59 then return nil end
46
+ return days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second
47
+ end
48
+
49
+ --- Default-path of a request path (RFC 6265 section 5.1.4).
50
+ function M.default_path(path)
51
+ path = url_util.path_only(path or "/")
52
+ if path:sub(1, 1) ~= "/" then return "/" end
53
+ local last = path:match("^.*()/")
54
+ if not last or last == 1 then return "/" end
55
+ return path:sub(1, last - 1)
56
+ end
57
+
58
+ local function is_ip(host)
59
+ return host:match("^%d+%.%d+%.%d+%.%d+$") ~= nil or host:find(":", 1, true) ~= nil
60
+ end
61
+
62
+ function M.domain_match(host, domain)
63
+ if host == domain then return true end
64
+ if is_ip(host) then return false end
65
+ return #host > #domain and host:sub(-#domain - 1) == "." .. domain
66
+ end
67
+
68
+ function M.path_match(request_path, cookie_path)
69
+ if request_path == cookie_path then return true end
70
+ if request_path:sub(1, #cookie_path) ~= cookie_path then return false end
71
+ return cookie_path:sub(-1) == "/" or request_path:sub(#cookie_path + 1, #cookie_path + 1) == "/"
72
+ end
73
+
74
+ --- Parse one Set-Cookie header received from `target` (a butter.url table).
75
+ -- @return table|nil, string|nil
76
+ function M.parse_set_cookie(header, target, now)
77
+ if type(header) ~= "string" then return nil, "not a string" end
78
+ local pair, attrs = header:match("^([^;]*)(.*)$")
79
+ local name, value
80
+ local eq = pair:find("=", 1, true)
81
+ if eq then
82
+ name, value = trim(pair:sub(1, eq - 1)), trim(pair:sub(eq + 1))
83
+ else
84
+ name, value = "", trim(pair)
85
+ end
86
+ if name == "" and value == "" then return nil, "empty cookie" end
87
+ if #name + #value > 4096 then return nil, "cookie too large" end
88
+ if (name .. value):find("%c") then return nil, "control characters in cookie" end
89
+
90
+ local cookie = { name = name, value = value, secure = false, http_only = false }
91
+ local max_age, expires, domain, path
92
+ for attr in attrs:gmatch(";([^;]*)") do
93
+ local k, v = attr:match("^%s*([^=]-)%s*=%s*(.-)%s*$")
94
+ if not k then
95
+ k, v = trim(attr), ""
96
+ end
97
+ k = k:lower()
98
+ if k == "expires" then
99
+ expires = M.parse_date(v) or expires
100
+ elseif k == "max-age" then
101
+ if v:match("^%-?%d+$") then max_age = tonumber(v) end
102
+ elseif k == "domain" then
103
+ v = v:gsub("^%.", ""):lower()
104
+ if v ~= "" then domain = v end
105
+ elseif k == "path" then
106
+ if v:sub(1, 1) == "/" then path = v end
107
+ elseif k == "secure" then
108
+ cookie.secure = true
109
+ elseif k == "httponly" then
110
+ cookie.http_only = true
111
+ elseif k == "samesite" then
112
+ cookie.same_site = v:lower()
113
+ end
114
+ end
115
+
116
+ if max_age then
117
+ cookie.expires = max_age <= 0 and 0 or now + max_age
118
+ elseif expires then
119
+ cookie.expires = expires
120
+ end
121
+
122
+ local host = target.host
123
+ if domain then
124
+ if not M.domain_match(host, domain) then return nil, "domain does not match" end
125
+ -- Without a public suffix list, at least refuse single-label domains ("com").
126
+ if domain ~= host and not domain:find(".", 1, true) then return nil, "domain too broad" end
127
+ cookie.domain, cookie.host_only = domain, false
128
+ else
129
+ cookie.domain, cookie.host_only = host, true
130
+ end
131
+ cookie.path = path or M.default_path(target.path)
132
+
133
+ if cookie.secure and not target.secure then return nil, "secure cookie from insecure origin" end
134
+ if name:sub(1, 9) == "__Secure-" and not cookie.secure then return nil, "__Secure- prefix requires Secure" end
135
+ if name:sub(1, 7) == "__Host-" and not (cookie.secure and cookie.host_only and cookie.path == "/") then
136
+ return nil, "__Host- prefix requirements not met"
137
+ end
138
+ return cookie
139
+ end
140
+
141
+ --- Insert or replace `cookie` in `list`, dropping expired entries.
142
+ -- opts.from_script: the cookie comes from document.cookie (cannot touch HttpOnly)
143
+ -- opts.max: maximum cookies kept (oldest are evicted)
144
+ -- @return table the new list
145
+ function M.upsert(list, cookie, now, opts)
146
+ opts = opts or {}
147
+ if opts.from_script and cookie.http_only then return list end
148
+ local out, created = {}, nil
149
+ for _, c in ipairs(list) do
150
+ if c.expires and c.expires <= now then
151
+ -- expired: drop
152
+ elseif c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path then
153
+ if opts.from_script and c.http_only then return list end
154
+ created = c.created
155
+ else
156
+ out[#out + 1] = c
157
+ end
158
+ end
159
+ cookie.created = created or now
160
+ if not (cookie.expires and cookie.expires <= now) then out[#out + 1] = cookie end
161
+
162
+ local max = opts.max or 500
163
+ if #out > max then
164
+ table.sort(out, function(a, b) return (a.created or 0) > (b.created or 0) end)
165
+ for i = #out, max + 1, -1 do out[i] = nil end
166
+ end
167
+ return out
168
+ end
169
+
170
+ --- Cookie header value for a request to `target`.
171
+ -- opts.for_script: only cookies visible to document.cookie (no HttpOnly)
172
+ function M.header(list, target, now, opts)
173
+ local for_script = opts and opts.for_script
174
+ local path = url_util.path_only(target.path)
175
+ local matches = {}
176
+ for _, c in ipairs(list) do
177
+ if not (c.expires and c.expires <= now)
178
+ and ((c.host_only and c.domain == target.host) or (not c.host_only and M.domain_match(target.host, c.domain)))
179
+ and M.path_match(path, c.path)
180
+ and (not c.secure or target.secure)
181
+ and not (for_script and c.http_only)
182
+ then
183
+ matches[#matches + 1] = c
184
+ end
185
+ end
186
+ table.sort(matches, function(a, b)
187
+ if #a.path ~= #b.path then return #a.path > #b.path end
188
+ return (a.created or 0) < (b.created or 0)
189
+ end)
190
+ local parts = {}
191
+ for i, c in ipairs(matches) do
192
+ parts[i] = c.name ~= "" and (c.name .. "=" .. c.value) or c.value
193
+ end
194
+ return table.concat(parts, "; ")
195
+ end
196
+
197
+ return M