@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.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/butter-col.js +4 -0
- package/dist/bootstrap.html +67 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +7 -0
- package/dist/controller.mjs +2 -0
- package/dist/controller.mjs.map +7 -0
- package/dist/formatter.mjs +2 -0
- package/dist/formatter.mjs.map +7 -0
- package/dist/sw.js +30 -0
- package/dist/sw.js.map +7 -0
- package/dist/transports/butter.js +2 -0
- package/dist/transports/butter.js.map +7 -0
- package/dist/types/controller/index.d.ts +65 -0
- package/dist/types/formatter/codecs.d.ts +17 -0
- package/dist/types/formatter/css.d.ts +10 -0
- package/dist/types/formatter/html.d.ts +61 -0
- package/dist/types/formatter/index.d.ts +77 -0
- package/dist/types/formatter/js.d.ts +27 -0
- package/dist/types/formatter/url.d.ts +40 -0
- package/lib/cli.js +257 -0
- package/lib/index.d.ts +68 -0
- package/lib/index.js +166 -0
- package/package.json +77 -4
- package/server/conf/butter-proxy.conf.template +29 -0
- package/server/conf/mime.types +19 -0
- package/server/conf/nginx.conf.template +152 -0
- package/server/config.lua +34 -0
- package/server/lua/butter/config.lua +160 -0
- package/server/lua/butter/cookiejar.lua +197 -0
- package/server/lua/butter/dns.lua +67 -0
- package/server/lua/butter/guard.lua +78 -0
- package/server/lua/butter/headers.lua +83 -0
- package/server/lua/butter/init.lua +18 -0
- package/server/lua/butter/ip.lua +165 -0
- package/server/lua/butter/limit.lua +18 -0
- package/server/lua/butter/session.lua +42 -0
- package/server/lua/butter/store.lua +77 -0
- package/server/lua/butter/transport.lua +248 -0
- package/server/lua/butter/url.lua +78 -0
- package/site/app.css +373 -0
- package/site/app.js +335 -0
- package/site/butter-col.png +0 -0
- package/site/index.html +139 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
-- nginx phase handlers for butter-col's endpoints.
|
|
2
|
+
--
|
|
3
|
+
-- /butter/fetch HTTP transport (access + header_filter around proxy_pass)
|
|
4
|
+
-- /butter/ws WebSocket transport (access + header_filter around proxy_pass)
|
|
5
|
+
-- /butter/cookie document.cookie write-back
|
|
6
|
+
-- /butter/config.js, /butter/health
|
|
7
|
+
--
|
|
8
|
+
-- The Lua code validates and prepares each request; nginx's proxy module
|
|
9
|
+
-- does the actual streaming, keep-alive and TLS work.
|
|
10
|
+
|
|
11
|
+
local cjson = require "cjson.safe"
|
|
12
|
+
local config = require "butter.config"
|
|
13
|
+
local url = require "butter.url"
|
|
14
|
+
local guard = require "butter.guard"
|
|
15
|
+
local headers = require "butter.headers"
|
|
16
|
+
local session = require "butter.session"
|
|
17
|
+
local store = require "butter.store"
|
|
18
|
+
local limit = require "butter.limit"
|
|
19
|
+
|
|
20
|
+
local M = {}
|
|
21
|
+
|
|
22
|
+
local function error_json(code, message)
|
|
23
|
+
return cjson.encode({ code = code, message = message })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
--- Send a JSON error and finish the request.
|
|
27
|
+
local function fail(status, code, message)
|
|
28
|
+
ngx.status = status
|
|
29
|
+
ngx.header["Content-Type"] = "application/json"
|
|
30
|
+
ngx.header["Cache-Control"] = "no-store"
|
|
31
|
+
ngx.header["X-Butter-Error"] = error_json(code, message)
|
|
32
|
+
ngx.say(cjson.encode({ error = { code = code, message = message } }))
|
|
33
|
+
return ngx.exit(ngx.HTTP_OK)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
--- "https://host:port" -> does it name this server?
|
|
37
|
+
local function origin_is_self(origin)
|
|
38
|
+
local host = origin:match("^%a[%w+.-]*://([^/]+)$")
|
|
39
|
+
return host ~= nil and host:lower() == (ngx.var.http_host or ""):lower()
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
--- Was this request made by our own pages (and not by another website)?
|
|
43
|
+
local function same_origin()
|
|
44
|
+
local cfg = config.get()
|
|
45
|
+
if not cfg.require_same_origin then return true end
|
|
46
|
+
local site = ngx.var.http_sec_fetch_site
|
|
47
|
+
if site then return site == "same-origin" end
|
|
48
|
+
local origin = ngx.var.http_origin
|
|
49
|
+
if origin then return origin_is_self(origin) end
|
|
50
|
+
return cfg.allow_non_browser
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
--- Shared checks; returns the parsed target and the upstream address.
|
|
54
|
+
local function prepare(raw_url, allowed_schemes)
|
|
55
|
+
local cfg = config.get()
|
|
56
|
+
if not limit.check(cfg.rate_limit) then
|
|
57
|
+
return fail(429, "RATE_LIMITED", "Too many requests. Slow down a little.")
|
|
58
|
+
end
|
|
59
|
+
if not same_origin() then
|
|
60
|
+
return fail(403, "FORBIDDEN_ORIGIN", "Transport requests must come from this server's own pages.")
|
|
61
|
+
end
|
|
62
|
+
local target, err = url.parse(raw_url)
|
|
63
|
+
if not target then return fail(400, "INVALID_URL", err) end
|
|
64
|
+
if not allowed_schemes[target.scheme] then
|
|
65
|
+
return fail(400, "INVALID_URL", "Unsupported scheme " .. target.scheme .. ".")
|
|
66
|
+
end
|
|
67
|
+
local dest, code, message = guard.check(target)
|
|
68
|
+
if not dest then return fail(code == "DNS_FAILURE" and 502 or 403, code, message) end
|
|
69
|
+
return target, dest
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local function set_upstream(scheme, target, dest)
|
|
73
|
+
ngx.var.butter_scheme = scheme
|
|
74
|
+
ngx.var.butter_addr = dest.addr
|
|
75
|
+
ngx.var.butter_uri = target.path
|
|
76
|
+
ngx.var.butter_host = target.host_header
|
|
77
|
+
ngx.var.butter_sni = target.host
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
--- Remove every incoming header except the ones in `keep`.
|
|
81
|
+
local function clear_request_headers(keep)
|
|
82
|
+
for name in pairs(ngx.req.get_headers(200, true)) do
|
|
83
|
+
if not keep[name:lower()] then ngx.req.clear_header(name) end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function apply_cookie(sid, target)
|
|
88
|
+
if not config.get().cookies.enabled then return end
|
|
89
|
+
local cookie = store.cookie_header(sid, target)
|
|
90
|
+
if cookie ~= "" then ngx.req.set_header("Cookie", cookie) end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
local HTTP_SCHEMES = { http = true, https = true }
|
|
94
|
+
local WS_SCHEMES = { ws = true, wss = true }
|
|
95
|
+
|
|
96
|
+
-- /butter/fetch ---------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function M.access_fetch()
|
|
99
|
+
local target, dest = prepare(ngx.var.http_x_butter_url, HTTP_SCHEMES)
|
|
100
|
+
if not target then return end
|
|
101
|
+
|
|
102
|
+
local decoded = cjson.decode(ngx.var.http_x_butter_headers or "{}")
|
|
103
|
+
local list, err = headers.sanitize_request(decoded)
|
|
104
|
+
if not list then return fail(400, "INVALID_HEADERS", err) end
|
|
105
|
+
|
|
106
|
+
local want_cookies = ngx.var.http_x_butter_want_cookies == "1"
|
|
107
|
+
local sid, new_session = session.get()
|
|
108
|
+
|
|
109
|
+
clear_request_headers(headers.KEEP_OUTER)
|
|
110
|
+
for _, header in ipairs(list) do
|
|
111
|
+
ngx.req.set_header(header[1], header[2])
|
|
112
|
+
end
|
|
113
|
+
local cfg = config.get()
|
|
114
|
+
if cfg.user_agent then ngx.req.set_header("User-Agent", cfg.user_agent) end
|
|
115
|
+
apply_cookie(sid, target)
|
|
116
|
+
|
|
117
|
+
set_upstream(target.scheme, target, dest)
|
|
118
|
+
ngx.ctx.butter = { sid = sid, new_session = new_session, target = target, want_cookies = want_cookies }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
--- Collect upstream Set-Cookie headers into the jar.
|
|
122
|
+
local function capture_cookies(ctx, values)
|
|
123
|
+
if #values > 0 and config.get().cookies.enabled then
|
|
124
|
+
store.set_cookies(ctx.sid, ctx.target, values)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
function M.header_filter_fetch()
|
|
129
|
+
local ctx = ngx.ctx.butter
|
|
130
|
+
if not ctx then return end
|
|
131
|
+
|
|
132
|
+
local status = ngx.status
|
|
133
|
+
local reported, cookies = {}, {}
|
|
134
|
+
for name, value in pairs(ngx.resp.get_headers(200, true)) do
|
|
135
|
+
local lower = name:lower()
|
|
136
|
+
local values = type(value) == "table" and value or { value }
|
|
137
|
+
if lower == "set-cookie" then
|
|
138
|
+
for _, v in ipairs(values) do cookies[#cookies + 1] = v end
|
|
139
|
+
elseif not headers.RESPONSE_SKIP[lower] then
|
|
140
|
+
for _, v in ipairs(values) do reported[#reported + 1] = { name, v } end
|
|
141
|
+
end
|
|
142
|
+
if not headers.OUTER_KEEP[lower] then ngx.header[name] = nil end
|
|
143
|
+
end
|
|
144
|
+
capture_cookies(ctx, cookies)
|
|
145
|
+
|
|
146
|
+
-- The real status travels in a header: a 3xx would make the browser follow
|
|
147
|
+
-- the redirect itself, and a 401 would pop up an auth dialog.
|
|
148
|
+
ngx.status = ngx.HTTP_OK
|
|
149
|
+
ngx.header["X-Butter-Status"] = tostring(status)
|
|
150
|
+
ngx.header["X-Butter-Status-Text"] = headers.reason(status)
|
|
151
|
+
ngx.header["X-Butter-Headers"] = #reported == 0 and "[]" or cjson.encode(reported)
|
|
152
|
+
ngx.header["Content-Type"] = "application/octet-stream"
|
|
153
|
+
ngx.header["Cache-Control"] = "no-store"
|
|
154
|
+
if ctx.want_cookies then
|
|
155
|
+
ngx.header["X-Butter-Document-Cookies"] = store.document_cookie(ctx.sid, ctx.target)
|
|
156
|
+
end
|
|
157
|
+
if ctx.new_session then ngx.header["Set-Cookie"] = session.cookie(ctx.sid) end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
-- /butter/ws -------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function M.access_ws()
|
|
163
|
+
if (ngx.var.http_upgrade or ""):lower() ~= "websocket" then
|
|
164
|
+
return fail(400, "NOT_WEBSOCKET", "Expected a WebSocket upgrade request.")
|
|
165
|
+
end
|
|
166
|
+
local args = ngx.req.get_uri_args(10)
|
|
167
|
+
local raw_url = type(args.url) == "string" and args.url or nil
|
|
168
|
+
local target, dest = prepare(raw_url, WS_SCHEMES)
|
|
169
|
+
if not target then return end
|
|
170
|
+
|
|
171
|
+
-- Origin the target site should see: the proxied page's own origin.
|
|
172
|
+
local page_origin = type(args.origin) == "string" and url.parse(args.origin .. "/")
|
|
173
|
+
local origin = page_origin and HTTP_SCHEMES[page_origin.scheme] and page_origin.origin
|
|
174
|
+
or ((target.secure and "https://" or "http://") .. target.host_header)
|
|
175
|
+
|
|
176
|
+
local sid, new_session = session.get()
|
|
177
|
+
for _, name in ipairs({ "Cookie", "Origin", "Referer", "X-Forwarded-For", "X-Real-IP", "Forwarded" }) do
|
|
178
|
+
ngx.req.clear_header(name)
|
|
179
|
+
end
|
|
180
|
+
ngx.req.set_header("Origin", origin)
|
|
181
|
+
local cfg = config.get()
|
|
182
|
+
if cfg.user_agent then ngx.req.set_header("User-Agent", cfg.user_agent) end
|
|
183
|
+
apply_cookie(sid, target)
|
|
184
|
+
|
|
185
|
+
set_upstream(target.secure and "https" or "http", target, dest)
|
|
186
|
+
ngx.ctx.butter = { sid = sid, new_session = new_session, target = target }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
function M.header_filter_ws()
|
|
190
|
+
local ctx = ngx.ctx.butter
|
|
191
|
+
if not ctx then return end
|
|
192
|
+
local values = ngx.header["Set-Cookie"]
|
|
193
|
+
if values then
|
|
194
|
+
capture_cookies(ctx, type(values) == "table" and values or { values })
|
|
195
|
+
ngx.header["Set-Cookie"] = nil
|
|
196
|
+
end
|
|
197
|
+
if ctx.new_session then ngx.header["Set-Cookie"] = session.cookie(ctx.sid) end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
-- Errors from nginx's proxy module (connection refused, TLS failure, timeout).
|
|
201
|
+
function M.upstream_error()
|
|
202
|
+
local status = ngx.status
|
|
203
|
+
if status == 504 then
|
|
204
|
+
return fail(504, "UPSTREAM_TIMEOUT", "The site took too long to respond.")
|
|
205
|
+
end
|
|
206
|
+
return fail(502, "UPSTREAM_UNREACHABLE", "Could not connect to the site (connection refused or reset, or its TLS certificate could not be verified).")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
-- /butter/cookie ---------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function M.cookie()
|
|
212
|
+
if ngx.req.get_method() ~= "POST" then return fail(405, "METHOD_NOT_ALLOWED", "Use POST.") end
|
|
213
|
+
if not same_origin() then return fail(403, "FORBIDDEN_ORIGIN", "Cross-site cookie writes are not allowed.") end
|
|
214
|
+
if not config.get().cookies.enabled then return ngx.exit(ngx.HTTP_NO_CONTENT) end
|
|
215
|
+
|
|
216
|
+
ngx.req.read_body()
|
|
217
|
+
local data = cjson.decode(ngx.req.get_body_data() or "")
|
|
218
|
+
if type(data) ~= "table" or type(data.url) ~= "string" or type(data.cookie) ~= "string" or #data.cookie > 4096 then
|
|
219
|
+
return fail(400, "INVALID_COOKIE", "Expected {\"url\": string, \"cookie\": string}.")
|
|
220
|
+
end
|
|
221
|
+
local target, err = url.parse(data.url)
|
|
222
|
+
if not target or not HTTP_SCHEMES[target.scheme] then return fail(400, "INVALID_URL", err or "Unsupported scheme.") end
|
|
223
|
+
|
|
224
|
+
local sid = session.ensure()
|
|
225
|
+
store.set_cookies(sid, target, { data.cookie }, { from_script = true })
|
|
226
|
+
return ngx.exit(ngx.HTTP_NO_CONTENT)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
-- /butter/config.js ------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function M.config_js()
|
|
232
|
+
-- Handing out the session cookie here (the service worker imports this
|
|
233
|
+
-- script on install) means it exists before the first proxied request.
|
|
234
|
+
session.ensure()
|
|
235
|
+
ngx.header["Content-Type"] = "text/javascript; charset=utf-8"
|
|
236
|
+
ngx.header["Cache-Control"] = "no-cache"
|
|
237
|
+
ngx.say("self.__butter$config = ", cjson.encode(config.public()), ";")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
-- /butter/health ---------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
function M.health()
|
|
243
|
+
ngx.header["Content-Type"] = "application/json"
|
|
244
|
+
ngx.header["Cache-Control"] = "no-store"
|
|
245
|
+
ngx.say(cjson.encode({ ok = true, name = "butter-col", version = config.VERSION }))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
return M
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Strict parser for the absolute URLs the service worker sends.
|
|
2
|
+
-- Pure Lua (5.1 / LuaJIT / 5.3), no ngx dependency.
|
|
3
|
+
|
|
4
|
+
local M = {}
|
|
5
|
+
|
|
6
|
+
local DEFAULT_PORTS = { http = 80, https = 443, ws = 80, wss = 443 }
|
|
7
|
+
local SECURE = { https = true, wss = true }
|
|
8
|
+
|
|
9
|
+
--- Parse an absolute http(s)/ws(s) URL.
|
|
10
|
+
-- @return table|nil, string|nil parsed URL, or nil and an error message
|
|
11
|
+
function M.parse(s)
|
|
12
|
+
if type(s) ~= "string" or s == "" then return nil, "missing URL" end
|
|
13
|
+
if #s > 8192 then return nil, "URL is too long" end
|
|
14
|
+
-- URL.href from the browser is always ASCII without spaces or controls;
|
|
15
|
+
-- anything else could smuggle CR/LF into the upstream request line.
|
|
16
|
+
if s:find("%c") or s:find(" ", 1, true) or s:find("[\128-\255]") then
|
|
17
|
+
return nil, "URL contains invalid characters"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
local scheme, rest = s:match("^(%a[%w+.-]*)://(.*)$")
|
|
21
|
+
if not scheme then return nil, "not an absolute URL" end
|
|
22
|
+
scheme = scheme:lower()
|
|
23
|
+
local default_port = DEFAULT_PORTS[scheme]
|
|
24
|
+
if not default_port then return nil, "unsupported scheme: " .. scheme end
|
|
25
|
+
|
|
26
|
+
local authority, path = rest:match("^([^/?#]*)(.*)$")
|
|
27
|
+
path = path:gsub("#.*$", "")
|
|
28
|
+
if path == "" then
|
|
29
|
+
path = "/"
|
|
30
|
+
elseif path:sub(1, 1) == "?" then
|
|
31
|
+
path = "/" .. path
|
|
32
|
+
end
|
|
33
|
+
if path:sub(1, 1) ~= "/" then return nil, "invalid path" end
|
|
34
|
+
|
|
35
|
+
if authority:find("@", 1, true) then return nil, "credentials in URLs are not allowed" end
|
|
36
|
+
|
|
37
|
+
local host, port, ipv6
|
|
38
|
+
if authority:sub(1, 1) == "[" then
|
|
39
|
+
host, port = authority:match("^%[([%x:%.]+)%]:?(%d*)$")
|
|
40
|
+
if not host then return nil, "invalid IPv6 host" end
|
|
41
|
+
ipv6 = true
|
|
42
|
+
else
|
|
43
|
+
host, port = authority:match("^([^:]+):?(%d*)$")
|
|
44
|
+
if not host then return nil, "invalid host" end
|
|
45
|
+
host = host:gsub("%.$", "")
|
|
46
|
+
if host == "" or #host > 253 or not host:match("^[%w%-%._]+$") then
|
|
47
|
+
return nil, "invalid host"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
host = host:lower()
|
|
51
|
+
|
|
52
|
+
local port_num = default_port
|
|
53
|
+
if port ~= "" then
|
|
54
|
+
port_num = tonumber(port)
|
|
55
|
+
if not port_num or port_num < 1 or port_num > 65535 then return nil, "invalid port" end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local host_header = ipv6 and ("[" .. host .. "]") or host
|
|
59
|
+
if port_num ~= default_port then host_header = host_header .. ":" .. string.format("%d", port_num) end
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
scheme = scheme,
|
|
63
|
+
host = host,
|
|
64
|
+
port = port_num,
|
|
65
|
+
path = path, -- path and query, percent-encoded
|
|
66
|
+
ipv6 = ipv6 or false,
|
|
67
|
+
secure = SECURE[scheme] or false,
|
|
68
|
+
host_header = host_header, -- value for the Host header
|
|
69
|
+
origin = scheme .. "://" .. host_header,
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
--- Path without the query string.
|
|
74
|
+
function M.path_only(path)
|
|
75
|
+
return (path:gsub("%?.*$", ""))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return M
|
package/site/app.css
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/* butter-col frontend
|
|
2
|
+
*
|
|
3
|
+
* The identity comes from the logo: a glossy stick of butter with a thick
|
|
4
|
+
* orange outline, on a cool pantry-blue night. The omnibox is drawn as that
|
|
5
|
+
* stick of butter; everything around it stays quiet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--pantry: #1a2142; /* page background */
|
|
10
|
+
--shelf: #242d57; /* toolbar, panels */
|
|
11
|
+
--shelf-high: #2f3a6b; /* hover, inputs on panels */
|
|
12
|
+
--line: #3a467c;
|
|
13
|
+
--milk: #fff7dc; /* text */
|
|
14
|
+
--haze: #a3abd1; /* secondary text */
|
|
15
|
+
--butter: #ffd447;
|
|
16
|
+
--butter-light: #ffe58a;
|
|
17
|
+
--butter-deep: #f5bd2c;
|
|
18
|
+
--crust: #f08a1c; /* the logo's orange outline */
|
|
19
|
+
--crust-deep: #c9650b;
|
|
20
|
+
--on-butter: #3b2a05; /* text on butter */
|
|
21
|
+
--danger: #ff8f7a;
|
|
22
|
+
--font-display: "Fredoka", "Trebuchet MS", system-ui, sans-serif;
|
|
23
|
+
--font-text: "Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
24
|
+
color-scheme: dark;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* light: a pale periwinkle kitchen in daylight */
|
|
28
|
+
@media (prefers-color-scheme: light) {
|
|
29
|
+
:root:not([data-theme="dark"]) {
|
|
30
|
+
--pantry: #eef0ff;
|
|
31
|
+
--shelf: #ffffff;
|
|
32
|
+
--shelf-high: #e3e7fb;
|
|
33
|
+
--line: #cdd3f2;
|
|
34
|
+
--milk: #1a2142;
|
|
35
|
+
--haze: #59618c;
|
|
36
|
+
--danger: #b3261e;
|
|
37
|
+
color-scheme: light;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
:root[data-theme="light"] {
|
|
41
|
+
--pantry: #eef0ff;
|
|
42
|
+
--shelf: #ffffff;
|
|
43
|
+
--shelf-high: #e3e7fb;
|
|
44
|
+
--line: #cdd3f2;
|
|
45
|
+
--milk: #1a2142;
|
|
46
|
+
--haze: #59618c;
|
|
47
|
+
--danger: #b3261e;
|
|
48
|
+
color-scheme: light;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
52
|
+
|
|
53
|
+
html, body { height: 100%; }
|
|
54
|
+
|
|
55
|
+
body {
|
|
56
|
+
margin: 0;
|
|
57
|
+
background: var(--pantry);
|
|
58
|
+
color: var(--milk);
|
|
59
|
+
font: 17px/1.5 var(--font-text);
|
|
60
|
+
-webkit-font-smoothing: antialiased;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
button, input, select { font: inherit; color: inherit; }
|
|
64
|
+
|
|
65
|
+
:focus-visible { outline: 3px solid var(--crust); outline-offset: 2px; }
|
|
66
|
+
|
|
67
|
+
[hidden] { display: none !important; }
|
|
68
|
+
|
|
69
|
+
.visually-hidden {
|
|
70
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
71
|
+
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---- shared controls ---------------------------------------------------- */
|
|
75
|
+
|
|
76
|
+
.icon-button {
|
|
77
|
+
flex: none;
|
|
78
|
+
display: inline-grid;
|
|
79
|
+
place-items: center;
|
|
80
|
+
width: 40px;
|
|
81
|
+
height: 40px;
|
|
82
|
+
border: 0;
|
|
83
|
+
border-radius: 12px;
|
|
84
|
+
background: transparent;
|
|
85
|
+
color: var(--haze);
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
}
|
|
88
|
+
.icon-button:hover { background: var(--shelf-high); color: var(--milk); }
|
|
89
|
+
.icon-button:disabled { opacity: 0.35; cursor: default; background: transparent; }
|
|
90
|
+
.icon-button svg {
|
|
91
|
+
width: 22px; height: 22px;
|
|
92
|
+
fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.text-button {
|
|
96
|
+
border: 0;
|
|
97
|
+
padding: 0 0.15em;
|
|
98
|
+
background: none;
|
|
99
|
+
color: var(--butter);
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
text-decoration: underline;
|
|
102
|
+
text-underline-offset: 3px;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
}
|
|
105
|
+
:root[data-theme="light"] .text-button { color: var(--crust-deep); }
|
|
106
|
+
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .text-button { color: var(--crust-deep); } }
|
|
107
|
+
|
|
108
|
+
.secondary-button {
|
|
109
|
+
justify-self: start;
|
|
110
|
+
padding: 0.55rem 1rem;
|
|
111
|
+
border: 2px solid var(--line);
|
|
112
|
+
border-radius: 12px;
|
|
113
|
+
background: var(--shelf-high);
|
|
114
|
+
font-weight: 700;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
.secondary-button:hover { border-color: var(--crust); }
|
|
118
|
+
|
|
119
|
+
.notice {
|
|
120
|
+
margin: 1rem auto 0;
|
|
121
|
+
max-width: 34rem;
|
|
122
|
+
padding: 0.75rem 1rem;
|
|
123
|
+
border-radius: 14px;
|
|
124
|
+
border: 2px solid var(--danger);
|
|
125
|
+
color: var(--milk);
|
|
126
|
+
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
127
|
+
text-align: left;
|
|
128
|
+
}
|
|
129
|
+
.notice[data-kind="info"] { border-color: var(--line); background: var(--shelf-high); }
|
|
130
|
+
|
|
131
|
+
/* ---- home --------------------------------------------------------------- */
|
|
132
|
+
|
|
133
|
+
.home {
|
|
134
|
+
min-height: 100%;
|
|
135
|
+
display: grid;
|
|
136
|
+
grid-template-rows: auto 1fr auto;
|
|
137
|
+
padding: 16px 16px 40px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.home-bar { display: flex; justify-content: flex-end; }
|
|
141
|
+
|
|
142
|
+
.hero {
|
|
143
|
+
align-self: center;
|
|
144
|
+
justify-self: center;
|
|
145
|
+
width: min(44rem, 100%);
|
|
146
|
+
text-align: center;
|
|
147
|
+
padding: 2vh 0 4vh;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.hero-logo {
|
|
151
|
+
display: block;
|
|
152
|
+
width: min(26rem, 82vw);
|
|
153
|
+
height: auto;
|
|
154
|
+
margin: 0 auto 1.25rem;
|
|
155
|
+
transform-origin: 50% 90%;
|
|
156
|
+
animation: settle 700ms cubic-bezier(.2, 1.4, .4, 1) both;
|
|
157
|
+
}
|
|
158
|
+
@keyframes settle {
|
|
159
|
+
from { transform: translateY(-14px) rotate(-3deg) scale(0.96); opacity: 0; }
|
|
160
|
+
to { transform: none; opacity: 1; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* The stick of butter. */
|
|
164
|
+
.stick {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: 8px;
|
|
168
|
+
padding: 8px 8px 8px 22px;
|
|
169
|
+
border: 4px solid var(--crust);
|
|
170
|
+
border-radius: 24px;
|
|
171
|
+
background:
|
|
172
|
+
linear-gradient(180deg, rgb(255 255 255 / 0.55) 0 6px, transparent 6px) top / 100% 6px no-repeat,
|
|
173
|
+
linear-gradient(180deg, var(--butter-light) 0%, var(--butter) 46%, var(--butter-deep) 100%);
|
|
174
|
+
box-shadow:
|
|
175
|
+
inset 0 -6px 0 rgb(201 101 11 / 0.22),
|
|
176
|
+
0 7px 0 var(--crust-deep),
|
|
177
|
+
0 18px 34px rgb(5 8 30 / 0.35);
|
|
178
|
+
}
|
|
179
|
+
.stick:focus-within { outline: 3px solid var(--milk); outline-offset: 4px; }
|
|
180
|
+
.stick:focus-within:has(:focus:not(:focus-visible)) { outline: none; }
|
|
181
|
+
|
|
182
|
+
.stick-input {
|
|
183
|
+
flex: 1;
|
|
184
|
+
min-width: 0;
|
|
185
|
+
border: 0;
|
|
186
|
+
background: transparent;
|
|
187
|
+
color: var(--on-butter);
|
|
188
|
+
font-family: var(--font-display);
|
|
189
|
+
font-weight: 500;
|
|
190
|
+
font-size: clamp(1.1rem, 2.6vw, 1.35rem);
|
|
191
|
+
padding: 0.55rem 0;
|
|
192
|
+
}
|
|
193
|
+
.stick-input::placeholder { color: rgb(59 42 5 / 0.62); }
|
|
194
|
+
.stick-input:focus { outline: none; }
|
|
195
|
+
|
|
196
|
+
.stick-go {
|
|
197
|
+
flex: none;
|
|
198
|
+
border: 0;
|
|
199
|
+
border-radius: 16px;
|
|
200
|
+
padding: 0.6rem 1.4rem;
|
|
201
|
+
background: var(--crust);
|
|
202
|
+
color: #fff7dc;
|
|
203
|
+
font-family: var(--font-display);
|
|
204
|
+
font-weight: 700;
|
|
205
|
+
font-size: 1.15rem;
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.18);
|
|
208
|
+
}
|
|
209
|
+
.stick-go:hover { background: var(--crust-deep); }
|
|
210
|
+
.stick-go:disabled { opacity: 0.6; cursor: progress; }
|
|
211
|
+
|
|
212
|
+
.transport-line {
|
|
213
|
+
margin: 1.6rem 0 0;
|
|
214
|
+
color: var(--haze);
|
|
215
|
+
font-size: 0.95rem;
|
|
216
|
+
}
|
|
217
|
+
.transport-line strong { color: var(--milk); font-weight: 700; }
|
|
218
|
+
|
|
219
|
+
.recent {
|
|
220
|
+
justify-self: center;
|
|
221
|
+
width: min(34rem, 100%);
|
|
222
|
+
}
|
|
223
|
+
.recent h2 {
|
|
224
|
+
margin: 0 0 0.5rem;
|
|
225
|
+
font: 600 1.05rem/1.3 var(--font-display);
|
|
226
|
+
color: var(--haze);
|
|
227
|
+
}
|
|
228
|
+
.recent-list { list-style: none; margin: 0; padding: 0; }
|
|
229
|
+
.recent-list button {
|
|
230
|
+
display: grid;
|
|
231
|
+
grid-template-columns: 2.25rem 1fr;
|
|
232
|
+
gap: 0.15rem 0.8rem;
|
|
233
|
+
align-items: center;
|
|
234
|
+
width: 100%;
|
|
235
|
+
padding: 0.5rem 0.6rem;
|
|
236
|
+
border: 0;
|
|
237
|
+
border-radius: 14px;
|
|
238
|
+
background: transparent;
|
|
239
|
+
text-align: left;
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
}
|
|
242
|
+
.recent-list button:hover { background: var(--shelf); }
|
|
243
|
+
.recent-badge {
|
|
244
|
+
grid-row: span 2;
|
|
245
|
+
display: grid;
|
|
246
|
+
place-items: center;
|
|
247
|
+
width: 2.25rem;
|
|
248
|
+
height: 2.25rem;
|
|
249
|
+
border-radius: 10px;
|
|
250
|
+
border: 2px solid var(--crust);
|
|
251
|
+
background: var(--butter);
|
|
252
|
+
color: var(--on-butter);
|
|
253
|
+
font: 700 1.05rem/1 var(--font-display);
|
|
254
|
+
text-transform: uppercase;
|
|
255
|
+
}
|
|
256
|
+
.recent-title { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
257
|
+
.recent-host { color: var(--haze); font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
258
|
+
.recent-empty { margin: 0; color: var(--haze); font-size: 0.95rem; }
|
|
259
|
+
|
|
260
|
+
/* ---- browser ------------------------------------------------------------ */
|
|
261
|
+
|
|
262
|
+
.browser {
|
|
263
|
+
position: fixed;
|
|
264
|
+
inset: 0;
|
|
265
|
+
display: grid;
|
|
266
|
+
grid-template-rows: auto 1fr;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.toolbar {
|
|
270
|
+
position: relative;
|
|
271
|
+
display: flex;
|
|
272
|
+
align-items: center;
|
|
273
|
+
gap: 4px;
|
|
274
|
+
padding: 8px 10px;
|
|
275
|
+
background: var(--shelf);
|
|
276
|
+
border-bottom: 1px solid var(--line);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.address {
|
|
280
|
+
flex: 1;
|
|
281
|
+
min-width: 0;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 8px;
|
|
285
|
+
margin: 0 6px;
|
|
286
|
+
padding: 0 14px;
|
|
287
|
+
height: 42px;
|
|
288
|
+
border: 2px solid transparent;
|
|
289
|
+
border-radius: 14px;
|
|
290
|
+
background: var(--pantry);
|
|
291
|
+
}
|
|
292
|
+
.address:focus-within { border-color: var(--crust); }
|
|
293
|
+
.address-lock {
|
|
294
|
+
flex: none;
|
|
295
|
+
width: 17px; height: 17px;
|
|
296
|
+
fill: none; stroke: var(--haze); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
|
|
297
|
+
}
|
|
298
|
+
.address-lock[data-insecure] { stroke: var(--danger); }
|
|
299
|
+
.address-input {
|
|
300
|
+
flex: 1;
|
|
301
|
+
min-width: 0;
|
|
302
|
+
border: 0;
|
|
303
|
+
background: transparent;
|
|
304
|
+
font-size: 0.98rem;
|
|
305
|
+
}
|
|
306
|
+
.address-input:focus { outline: none; }
|
|
307
|
+
|
|
308
|
+
.progress {
|
|
309
|
+
position: absolute;
|
|
310
|
+
left: 0;
|
|
311
|
+
bottom: -1px;
|
|
312
|
+
height: 3px;
|
|
313
|
+
width: 0;
|
|
314
|
+
background: linear-gradient(90deg, var(--butter), var(--crust));
|
|
315
|
+
opacity: 0;
|
|
316
|
+
transition: width 1.6s cubic-bezier(.1, .7, .2, 1), opacity 0.3s;
|
|
317
|
+
}
|
|
318
|
+
.progress[data-state="loading"] { width: 82%; opacity: 1; }
|
|
319
|
+
.progress[data-state="done"] { width: 100%; opacity: 0; transition: width 0.2s, opacity 0.4s 0.2s; }
|
|
320
|
+
|
|
321
|
+
.frame {
|
|
322
|
+
width: 100%;
|
|
323
|
+
height: 100%;
|
|
324
|
+
border: 0;
|
|
325
|
+
background: #fff;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ---- settings ----------------------------------------------------------- */
|
|
329
|
+
|
|
330
|
+
.settings {
|
|
331
|
+
width: min(30rem, calc(100vw - 32px));
|
|
332
|
+
padding: 0;
|
|
333
|
+
border: 2px solid var(--line);
|
|
334
|
+
border-radius: 22px;
|
|
335
|
+
background: var(--shelf);
|
|
336
|
+
color: var(--milk);
|
|
337
|
+
box-shadow: 0 24px 60px rgb(5 8 30 / 0.45);
|
|
338
|
+
}
|
|
339
|
+
.settings::backdrop { background: rgb(10 13 34 / 0.6); }
|
|
340
|
+
.settings[open] { animation: rise 180ms ease-out; }
|
|
341
|
+
@keyframes rise { from { transform: translateY(10px); opacity: 0; } }
|
|
342
|
+
|
|
343
|
+
.settings-body { display: grid; gap: 1.2rem; padding: 18px 22px 24px; }
|
|
344
|
+
.settings-head { display: flex; align-items: center; justify-content: space-between; }
|
|
345
|
+
.settings-head h2 { margin: 0; font: 600 1.4rem/1.2 var(--font-display); }
|
|
346
|
+
|
|
347
|
+
.field { display: grid; gap: 0.4rem; }
|
|
348
|
+
.field label, .field-label { font-weight: 700; }
|
|
349
|
+
.field select {
|
|
350
|
+
width: 100%;
|
|
351
|
+
padding: 0.6rem 0.75rem;
|
|
352
|
+
border: 2px solid var(--line);
|
|
353
|
+
border-radius: 12px;
|
|
354
|
+
background: var(--shelf-high);
|
|
355
|
+
}
|
|
356
|
+
.field select:focus-visible { border-color: var(--crust); outline: none; }
|
|
357
|
+
.hint { margin: 0; color: var(--haze); font-size: 0.9rem; }
|
|
358
|
+
.settings .notice { margin: 0; }
|
|
359
|
+
|
|
360
|
+
/* ---- small screens ------------------------------------------------------ */
|
|
361
|
+
|
|
362
|
+
@media (max-width: 560px) {
|
|
363
|
+
.toolbar { gap: 0; padding: 6px; }
|
|
364
|
+
.icon-button { width: 36px; height: 36px; }
|
|
365
|
+
.toolbar [data-action="fullscreen"], .toolbar [data-action="popout"] { display: none; }
|
|
366
|
+
.address { margin: 0 4px; padding: 0 10px; }
|
|
367
|
+
.stick { padding-left: 16px; border-radius: 20px; }
|
|
368
|
+
.stick-go { padding: 0.55rem 1rem; }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@media (prefers-reduced-motion: reduce) {
|
|
372
|
+
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
373
|
+
}
|